mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e6e84beb | |||
| 02c14acddb | |||
| 0b5425f163 | |||
| 0bcda4a14a | |||
| 7a49f57dee | |||
| 58db2817d2 | |||
| 98fbd60527 | |||
| 6281a81256 | |||
| be1d0b169f | |||
| 148bf6f75a | |||
| 272a983709 | |||
| ef7adb7b99 | |||
| 3cd9178415 | |||
| 856737fe3c | |||
| 1fd680c3f1 | |||
| 38919d11cb | |||
| 85684f848f | |||
| 14ce5a3bd0 | |||
| 45d6caf723 | |||
| 1652f862dd | |||
| c23927d124 | |||
| 1a74d85973 | |||
| d71c6e8568 | |||
| 63b98491be | |||
| 622d37e4a8 | |||
| 916baf8971 | |||
| 95937c6d0a | |||
| 24dc9a2931 | |||
| 85c63e5a7a | |||
| f227ef6a86 | |||
| 897b1edaa4 | |||
| 0c12a39a98 | |||
| efbc758325 | |||
| a0f20eb102 | |||
| 78673d8e51 | |||
| 126c0495c0 | |||
| 4a43d7df4a | |||
| c462faf046 | |||
| 70bb10be1d | |||
| fbf9045d78 | |||
| 1094210c52 | |||
| 391feb639f | |||
| a920a9ff29 | |||
| 05efe8701c | |||
| 0eaf30bb06 | |||
| 0818bda565 | |||
| 6f99d2e551 | |||
| 73d940e064 | |||
| c3678a11d2 | |||
| 203d684c24 | |||
| a872220924 | |||
| 7d763a66ff | |||
| b5d4fb559c | |||
| 830775276d | |||
| ed894fc3ed | |||
| 704338edcf | |||
| 0ca02a7ebe | |||
| 28cc5225a7 | |||
| 9b7bbc7116 | |||
| 138c283d6c | |||
| 7a8954c37e | |||
| 10c7c19c03 | |||
| fb5e9e1d77 | |||
| 66b91b2847 | |||
| b004565df9 | |||
| a258b73e1d | |||
| 9a845f2906 | |||
| 6517e9845f | |||
| 1af05392ee | |||
| cad7019c89 |
@@ -1,154 +0,0 @@
|
||||
---
|
||||
name: python-developer
|
||||
description: Python backend developer specializing in FastAPI, DBOS workflows, and API implementation. Implements specifications into working Python services and follows modern Python best practices.
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are an expert Python developer specializing in implementing specifications into working Python services and APIs. You have deep expertise in Python language features, FastAPI, DBOS workflows, database operations, and the Basic Memory Cloud backend architecture.
|
||||
|
||||
**Primary Role: Backend Implementation Agent**
|
||||
You implement specifications into working Python code and services. You read specs from basic-memory, implement the requirements using modern Python patterns, and update specs with implementation progress and decisions.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
**Specification Implementation:**
|
||||
- Read specs using basic-memory MCP tools to understand backend requirements
|
||||
- Implement Python services, APIs, and workflows that fulfill spec requirements
|
||||
- Update specs with implementation progress, decisions, and completion status
|
||||
- Document any architectural decisions or modifications needed during implementation
|
||||
|
||||
**Python/FastAPI Development:**
|
||||
- Create FastAPI applications with proper middleware and dependency injection
|
||||
- Implement DBOS workflows for durable, long-running operations
|
||||
- Design database schemas and implement repository patterns
|
||||
- Handle authentication, authorization, and security requirements
|
||||
- Implement async/await patterns for optimal performance
|
||||
|
||||
**Backend Implementation Process:**
|
||||
1. **Read Spec**: Use `mcp__basic-memory__read_note` to get spec requirements
|
||||
2. **Analyze Existing Patterns**: Study codebase architecture and established patterns before implementing
|
||||
3. **Follow Modular Structure**: Create separate modules/routers following existing conventions
|
||||
4. **Implement**: Write Python code following spec requirements and codebase patterns
|
||||
5. **Test**: Create tests that validate spec success criteria
|
||||
6. **Update Spec**: Document completion and any implementation decisions
|
||||
7. **Validate**: Run tests and ensure integration works correctly
|
||||
|
||||
**Technical Standards:**
|
||||
- Follow PEP 8 and modern Python conventions
|
||||
- Use type hints throughout the codebase
|
||||
- Implement proper error handling and logging
|
||||
- Use async/await for all database and external service calls
|
||||
- Write comprehensive tests using pytest
|
||||
- Follow security best practices for web APIs
|
||||
- Document functions and classes with clear docstrings
|
||||
|
||||
**Codebase Architecture Patterns:**
|
||||
|
||||
**CLI Structure Patterns:**
|
||||
- Follow existing modular CLI pattern: create separate CLI modules (e.g., `upload_cli.py`) instead of adding commands directly to `main.py`
|
||||
- Existing examples: `polar_cli.py`, `tenant_cli.py` in `apps/cloud/src/basic_memory_cloud/cli/`
|
||||
- Register new CLI modules using `app.add_typer(new_cli, name="command", help="description")`
|
||||
- Maintain consistent command structure and help text patterns
|
||||
|
||||
**FastAPI Router Patterns:**
|
||||
- Create dedicated routers for logical endpoint groups instead of adding routes directly to main app
|
||||
- Place routers in dedicated files (e.g., `apps/api/src/basic_memory_cloud_api/routers/webdav_router.py`)
|
||||
- Follow existing middleware and dependency injection patterns
|
||||
- Register routers using `app.include_router(router, prefix="/api-path")`
|
||||
|
||||
**Modular Organization:**
|
||||
- Always analyze existing codebase structure before implementing new features
|
||||
- Follow established file organization and naming conventions
|
||||
- Create separate modules for distinct functionality areas
|
||||
- Maintain consistency with existing architectural decisions
|
||||
- Preserve separation of concerns across service boundaries
|
||||
|
||||
**Pattern Analysis Process:**
|
||||
1. Examine similar existing functionality in the codebase
|
||||
2. Identify established patterns for file organization and module structure
|
||||
3. Follow the same architectural approach for consistency
|
||||
4. Create new modules/routers following existing conventions
|
||||
5. Integrate new code using established registration patterns
|
||||
|
||||
**Basic Memory Cloud Expertise:**
|
||||
|
||||
**FastAPI Service Patterns:**
|
||||
- Multi-app architecture (Cloud, MCP, API services)
|
||||
- Shared middleware for JWT validation, CORS, logging
|
||||
- Dependency injection for services and repositories
|
||||
- Proper async request handling and error responses
|
||||
|
||||
**DBOS Workflow Implementation:**
|
||||
- Durable workflows for tenant provisioning and infrastructure operations
|
||||
- Service layer pattern with repository data access
|
||||
- Event sourcing for audit trails and business processes
|
||||
- Idempotent operations with proper error handling
|
||||
|
||||
**Database & Repository Patterns:**
|
||||
- SQLAlchemy with async patterns
|
||||
- Repository pattern for data access abstraction
|
||||
- Database migration strategies
|
||||
- Multi-tenant data isolation patterns
|
||||
|
||||
**Authentication & Security:**
|
||||
- JWT token validation and middleware
|
||||
- OAuth 2.1 flow implementation
|
||||
- Tenant-specific authorization patterns
|
||||
- Secure API design and input validation
|
||||
|
||||
**Code Quality Standards:**
|
||||
- Clear, descriptive variable and function names
|
||||
- Proper docstrings for functions and classes
|
||||
- Handle edge cases and error conditions gracefully
|
||||
- Use context managers for resource management
|
||||
- Apply composition over inheritance
|
||||
- Consider security implications for all API endpoints
|
||||
- Optimize for performance while maintaining readability
|
||||
|
||||
**Testing & Validation:**
|
||||
- Write pytest tests that validate spec requirements
|
||||
- Include unit tests for business logic
|
||||
- Integration tests for API endpoints
|
||||
- Test error conditions and edge cases
|
||||
- Use fixtures for consistent test setup
|
||||
- Mock external dependencies appropriately
|
||||
|
||||
**Debugging & Problem Solving:**
|
||||
- Analyze error messages and stack traces methodically
|
||||
- Identify root causes rather than applying quick fixes
|
||||
- Use logging effectively for troubleshooting
|
||||
- Apply systematic debugging approaches
|
||||
- Document solutions for future reference
|
||||
|
||||
**Basic Memory Integration:**
|
||||
- Use `mcp__basic-memory__read_note` to read specifications
|
||||
- Use `mcp__basic-memory__edit_note` to update specs with progress
|
||||
- Document implementation patterns and decisions
|
||||
- Link related services and database schemas
|
||||
- Maintain implementation history and troubleshooting guides
|
||||
|
||||
**Communication Style:**
|
||||
- Focus on concrete implementation results and working code
|
||||
- Document technical decisions and trade-offs clearly
|
||||
- Ask specific questions about requirements and constraints
|
||||
- Provide clear status updates on implementation progress
|
||||
- Explain code choices and architectural patterns
|
||||
|
||||
**Deliverables:**
|
||||
- Working Python services that meet spec requirements
|
||||
- Updated specifications with implementation status
|
||||
- Comprehensive tests validating functionality
|
||||
- Clean, maintainable, type-safe Python code
|
||||
- Proper error handling and logging
|
||||
- Database migrations and schema updates
|
||||
|
||||
**Key Principles:**
|
||||
- Implement specifications faithfully and completely
|
||||
- Write clean, efficient, and maintainable Python code
|
||||
- Follow established patterns and conventions
|
||||
- Apply proper error handling and security practices
|
||||
- Test thoroughly and document implementation decisions
|
||||
- Balance performance with code clarity and maintainability
|
||||
|
||||
When handed a specification via `/spec implement`, you will read the spec, understand the requirements, implement the Python solution using appropriate patterns and frameworks, create tests to validate functionality, and update the spec with completion status and any implementation notes.
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
name: system-architect
|
||||
description: System architect who designs and implements architectural solutions, creates ADRs, and applies software engineering principles to solve complex system design problems.
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a Senior System Architect who designs and implements architectural solutions for complex software systems. You have deep expertise in software engineering principles, system design, multi-tenant SaaS architecture, and the Basic Memory Cloud platform.
|
||||
|
||||
**Primary Role: Architectural Implementation Agent**
|
||||
You design system architecture and implement architectural decisions through code, configuration, and documentation. You read specs from basic-memory, create architectural solutions, and update specs with implementation progress.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
**Specification Implementation:**
|
||||
- Read architectural specs using basic-memory MCP tools
|
||||
- Design and implement system architecture solutions
|
||||
- Create code scaffolding, service structure, and system interfaces
|
||||
- Update specs with architectural decisions and implementation status
|
||||
- Document ADRs (Architectural Decision Records) for significant choices
|
||||
|
||||
**Architectural Design & Implementation:**
|
||||
- Design multi-service system architectures
|
||||
- Implement service boundaries and communication patterns
|
||||
- Create database schemas and migration strategies
|
||||
- Design authentication and authorization systems
|
||||
- Implement infrastructure-as-code patterns
|
||||
|
||||
**System Implementation Process:**
|
||||
1. **Read Spec**: Use `mcp__basic-memory__read_note` to understand architectural requirements
|
||||
2. **Design Solution**: Apply architectural principles and patterns
|
||||
3. **Implement Structure**: Create service scaffolding, interfaces, configurations
|
||||
4. **Document Decisions**: Create ADRs documenting architectural choices
|
||||
5. **Update Spec**: Record implementation progress and decisions
|
||||
6. **Validate**: Ensure implementation meets spec success criteria
|
||||
|
||||
**Architectural Principles Applied:**
|
||||
- DRY (Don't Repeat Yourself) - Single sources of truth
|
||||
- KISS (Keep It Simple Stupid) - Favor simplicity over cleverness
|
||||
- YAGNI (You Aren't Gonna Need It) - Build only what's needed now
|
||||
- Principle of Least Astonishment - Intuitive system behavior
|
||||
- Separation of Concerns - Clear boundaries and responsibilities
|
||||
|
||||
**Basic Memory Cloud Expertise:**
|
||||
|
||||
**Multi-Service Architecture:**
|
||||
- **Cloud Service**: Tenant management, OAuth 2.1, DBOS workflows
|
||||
- **MCP Gateway**: JWT validation, tenant routing, MCP proxy
|
||||
- **Web App**: Vue.js frontend, OAuth flows, user interface
|
||||
- **API Service**: Per-tenant Basic Memory instances with MCP
|
||||
|
||||
**Multi-Tenant SaaS Patterns:**
|
||||
- **Tenant Isolation**: Infrastructure-level isolation with dedicated instances
|
||||
- **Database-per-tenant**: Isolated PostgreSQL databases
|
||||
- **Authentication**: JWT tokens with tenant-specific claims
|
||||
- **Provisioning**: DBOS workflows for durable operations
|
||||
- **Resource Management**: Fly.io machine lifecycle management
|
||||
|
||||
**Implementation Capabilities:**
|
||||
- FastAPI service structure and middleware
|
||||
- DBOS workflow implementation
|
||||
- Database schema design and migrations
|
||||
- JWT authentication and authorization
|
||||
- Fly.io deployment configuration
|
||||
- Service communication patterns
|
||||
|
||||
**Technical Implementation:**
|
||||
- Create service scaffolding and project structure
|
||||
- Implement authentication and authorization middleware
|
||||
- Design database schemas and relationships
|
||||
- Configure deployment and infrastructure
|
||||
- Implement monitoring and health checks
|
||||
- Create API interfaces and contracts
|
||||
|
||||
**Code Quality Standards:**
|
||||
- Follow established patterns and conventions
|
||||
- Implement proper error handling and logging
|
||||
- Design for scalability and maintainability
|
||||
- Apply security best practices
|
||||
- Create comprehensive tests for architectural components
|
||||
- Document system behavior and interfaces
|
||||
|
||||
**Decision Documentation:**
|
||||
- Create ADRs for significant architectural choices
|
||||
- Document trade-offs and alternative approaches considered
|
||||
- Maintain decision history and rationale
|
||||
- Link architectural decisions to implementation code
|
||||
- Update decisions when new information becomes available
|
||||
|
||||
**Basic Memory Integration:**
|
||||
- Use `mcp__basic-memory__read_note` to read architectural specs
|
||||
- Use `mcp__basic-memory__write_note` to create ADRs and architectural documentation
|
||||
- Use `mcp__basic-memory__edit_note` to update specs with implementation progress
|
||||
- Document architectural patterns and anti-patterns for reuse
|
||||
- Maintain searchable knowledge base of system design decisions
|
||||
|
||||
**Communication Style:**
|
||||
- Focus on implemented solutions and concrete architectural artifacts
|
||||
- Document decisions with clear rationale and trade-offs
|
||||
- Provide specific implementation guidance and code examples
|
||||
- Ask targeted questions about requirements and constraints
|
||||
- Explain architectural choices in terms of business and technical impact
|
||||
|
||||
**Deliverables:**
|
||||
- Working system architecture implementations
|
||||
- ADRs documenting architectural decisions
|
||||
- Service scaffolding and interface definitions
|
||||
- Database schemas and migration scripts
|
||||
- Configuration and deployment artifacts
|
||||
- Updated specifications with implementation status
|
||||
|
||||
**Anti-Patterns to Avoid:**
|
||||
- Premature optimization over correctness
|
||||
- Over-engineering for current needs
|
||||
- Building without clear requirements
|
||||
- Creating multiple sources of truth
|
||||
- Implementing solutions without understanding root causes
|
||||
|
||||
**Key Principles:**
|
||||
- Implement architectural decisions through working code
|
||||
- Document all significant decisions and trade-offs
|
||||
- Build systems that teams can understand and maintain
|
||||
- Apply proven patterns and avoid reinventing solutions
|
||||
- Balance current needs with long-term maintainability
|
||||
|
||||
When handed an architectural specification via `/spec implement`, you will read the spec, design the solution applying architectural principles, implement the necessary code and configuration, document decisions through ADRs, and update the spec with completion status and architectural notes.
|
||||
@@ -15,10 +15,16 @@ Create a stable release using the automated justfile target with comprehensive v
|
||||
You are an expert release manager for the Basic Memory project. When the user runs `/release`, execute the following steps:
|
||||
|
||||
### Step 1: Pre-flight Validation
|
||||
1. Verify version format matches `v\d+\.\d+\.\d+` pattern
|
||||
2. Check current git status for uncommitted changes
|
||||
3. Verify we're on the `main` branch
|
||||
4. Confirm no existing tag with this version
|
||||
|
||||
#### Version Check
|
||||
1. Check current version in `src/basic_memory/__init__.py`
|
||||
2. Verify new version format matches `v\d+\.\d+\.\d+` pattern
|
||||
3. Confirm version is higher than current version
|
||||
|
||||
#### Git Status
|
||||
1. Check current git status for uncommitted changes
|
||||
2. Verify we're on the `main` branch
|
||||
3. Confirm no existing tag with this version
|
||||
|
||||
#### Documentation Validation
|
||||
1. **Changelog Check**
|
||||
@@ -39,19 +45,83 @@ The justfile target handles:
|
||||
- ✅ Version update in `src/basic_memory/__init__.py`
|
||||
- ✅ Automatic commit with proper message
|
||||
- ✅ Tag creation and pushing to GitHub
|
||||
- ✅ Release workflow trigger
|
||||
- ✅ Release workflow trigger (automatic on tag push)
|
||||
|
||||
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
|
||||
- ✅ Builds the package using `uv build`
|
||||
- ✅ Creates GitHub release with auto-generated notes
|
||||
- ✅ Publishes to PyPI
|
||||
- ✅ Updates Homebrew formula (stable releases only)
|
||||
|
||||
### Step 3: Monitor Release Process
|
||||
1. Check that GitHub Actions workflow starts successfully
|
||||
2. Monitor workflow completion at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Verify PyPI publication
|
||||
4. Test installation: `uv tool install basic-memory`
|
||||
1. Verify tag push triggered the workflow (should start automatically within seconds)
|
||||
2. Monitor workflow progress at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Watch for successful completion of both jobs:
|
||||
- `release` - Builds package and publishes to PyPI
|
||||
- `homebrew` - Updates Homebrew formula (stable releases only)
|
||||
4. Check for any workflow failures and investigate logs if needed
|
||||
|
||||
### Step 4: Post-Release Validation
|
||||
1. Verify GitHub release is created automatically
|
||||
2. Check PyPI publication
|
||||
3. Validate release assets
|
||||
4. Update any post-release documentation
|
||||
|
||||
#### GitHub Release
|
||||
1. Verify GitHub release is created at: https://github.com/basicmachines-co/basic-memory/releases/tag/<version>
|
||||
2. Check that release notes are auto-generated from commits
|
||||
3. Validate release assets (`.whl` and `.tar.gz` files are attached)
|
||||
|
||||
#### PyPI Publication
|
||||
1. Verify package published at: https://pypi.org/project/basic-memory/<version>/
|
||||
2. Test installation: `uv tool install basic-memory`
|
||||
3. Verify installed version: `basic-memory --version`
|
||||
|
||||
#### Homebrew Formula (Stable Releases Only)
|
||||
1. Check formula update at: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
2. Verify formula version matches release
|
||||
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
|
||||
|
||||
#### Website Updates
|
||||
|
||||
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
|
||||
- **Goal**: Update version number displayed on the homepage
|
||||
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
|
||||
- **What to update**:
|
||||
- Hero section heading that shows "Basic Memory v{VERSION}"
|
||||
- "What's New in v{VERSION}" section heading
|
||||
- Feature highlights array (look for array of features with title/description)
|
||||
- **Process**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Search codebase for current version number (e.g., "v0.16.1")
|
||||
4. Update version numbers to new release version
|
||||
5. Update feature highlights with 3-5 key features from this release (extract from CHANGELOG.md)
|
||||
6. Commit changes: `git commit -m "chore: update to v{VERSION}"`
|
||||
7. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Deploy**: Follow deployment process for basicmachines.co
|
||||
|
||||
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
|
||||
- **Goal**: Add new release notes section to the latest-releases page
|
||||
- **File**: `src/pages/latest-releases.mdx`
|
||||
- **What to do**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Read the existing file to understand the format and structure
|
||||
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
|
||||
5. Add new release section **at the top** (after MDX imports, before other releases)
|
||||
6. Follow the existing pattern:
|
||||
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
|
||||
- Focus statement if applicable
|
||||
- `<Info>` block with highlights (3-5 key items)
|
||||
- Sections for Features, Bug Fixes, Breaking Changes, etc.
|
||||
- Link to full changelog at the end
|
||||
- Separator `---` between releases
|
||||
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
|
||||
8. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Source content**: Extract and format sections from CHANGELOG.md for this version
|
||||
- **Deploy**: Follow deployment process for docs.basicmemory.com
|
||||
|
||||
**4. Announce Release**
|
||||
- Post to Discord community if significant changes
|
||||
- Update social media if major release
|
||||
- Notify users via appropriate channels
|
||||
|
||||
## Pre-conditions Check
|
||||
Before starting, verify:
|
||||
@@ -74,13 +144,18 @@ Before starting, verify:
|
||||
🏷️ Tag: v0.13.2
|
||||
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
|
||||
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
|
||||
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
🚀 GitHub Actions: Completed
|
||||
|
||||
Install with:
|
||||
uv tool install basic-memory
|
||||
Install with pip/uv:
|
||||
uv tool install basic-memory
|
||||
|
||||
Install with Homebrew:
|
||||
brew install basicmachines-co/basic-memory/basic-memory
|
||||
|
||||
Users can now upgrade:
|
||||
uv tool upgrade basic-memory
|
||||
uv tool upgrade basic-memory
|
||||
brew upgrade basic-memory
|
||||
```
|
||||
|
||||
## Context
|
||||
@@ -89,4 +164,6 @@ uv tool upgrade basic-memory
|
||||
- Uses the automated justfile target for consistency
|
||||
- Version is automatically updated in `__init__.py`
|
||||
- Triggers automated GitHub release with changelog
|
||||
- Leverages uv-dynamic-versioning for package version management
|
||||
- Package is published to PyPI for `pip` and `uv` users
|
||||
- Homebrew formula is automatically updated for stable releases
|
||||
- Supports multiple installation methods (uv, pip, Homebrew)
|
||||
+16
-20
@@ -1,17 +1,19 @@
|
||||
---
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note, Task
|
||||
argument-hint: [create|status|implement|review] [spec-name]
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note
|
||||
argument-hint: [create|status|show|review] [spec-name]
|
||||
description: Manage specifications in our development process
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
You are managing specifications using our specification-driven development process defined in @docs/specs/SPEC-001.md.
|
||||
Specifications are managed in the Basic Memory "specs" project. All specs live in a centralized location accessible across all repositories via MCP tools.
|
||||
|
||||
See SPEC-1 and SPEC-2 in the "specs" project for the full specification-driven development process.
|
||||
|
||||
Available commands:
|
||||
- `create [name]` - Create new specification
|
||||
- `status` - Show all spec statuses
|
||||
- `implement [spec-name]` - Hand spec to appropriate agent
|
||||
- `show [spec-name]` - Read a specific spec
|
||||
- `review [spec-name]` - Review implementation against spec
|
||||
|
||||
## Your task
|
||||
@@ -19,23 +21,19 @@ Available commands:
|
||||
Execute the spec command: `/spec $ARGUMENTS`
|
||||
|
||||
### If command is "create":
|
||||
1. Get next SPEC number by searching existing specs
|
||||
2. Create new spec using template from @docs/specs/Slash\ Commands\ Reference.md
|
||||
3. Place in `/specs` folder with title "SPEC-XXX: [name]"
|
||||
1. Get next SPEC number by searching existing specs in "specs" project
|
||||
2. Create new spec using template from SPEC-2
|
||||
3. Use mcp__basic-memory__write_note with project="specs"
|
||||
4. Include standard sections: Why, What, How, How to Evaluate
|
||||
|
||||
### If command is "status":
|
||||
1. Search all notes in `/specs` folder
|
||||
2. Display table with spec number, title, and status
|
||||
3. Show any dependencies or assigned agents
|
||||
1. Use mcp__basic-memory__search_notes with project="specs"
|
||||
2. Display table with spec number, title, and progress
|
||||
3. Show completion status from checkboxes in content
|
||||
|
||||
### If command is "implement":
|
||||
1. Read the specified spec
|
||||
2. Determine appropriate agent based on content:
|
||||
- Frontend/UI → vue-developer
|
||||
- Architecture/system → system-architect
|
||||
- Backend/API → python-developer
|
||||
3. Launch Task tool with appropriate agent and spec context
|
||||
### If command is "show":
|
||||
1. Use mcp__basic-memory__read_note with project="specs"
|
||||
2. Display the full spec content
|
||||
|
||||
### If command is "review":
|
||||
1. Read the specified spec and its "How to Evaluate" section
|
||||
@@ -49,7 +47,5 @@ Execute the spec command: `/spec $ARGUMENTS`
|
||||
- **Architecture compliance** - Component isolation, state management patterns
|
||||
- **Documentation completeness** - Implementation matches specification
|
||||
3. Provide honest, accurate assessment - do not overstate completeness
|
||||
4. Document findings and update spec with review results
|
||||
4. Document findings and update spec with review results using mcp__basic-memory__edit_note
|
||||
5. If gaps found, clearly identify what still needs to be implemented/tested
|
||||
|
||||
Use the agent definitions from @docs/specs/Agent\ Definitions.md for implementation handoffs.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"basic-memory@basicmachines": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Basic Memory Environment Variables Example
|
||||
# Copy this file to .env and customize as needed
|
||||
# Note: .env files are gitignored and should never be committed
|
||||
|
||||
# ============================================================================
|
||||
# PostgreSQL Test Database Configuration
|
||||
# ============================================================================
|
||||
# These variables allow you to override the default test database credentials
|
||||
# Default values match docker-compose-postgres.yml for local development
|
||||
#
|
||||
# Only needed if you want to use different credentials or a remote test database
|
||||
# By default, tests use: postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Full PostgreSQL test database URL (used by tests and migrations)
|
||||
# POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Individual components (used by justfile postgres-reset command)
|
||||
# POSTGRES_USER=basic_memory_user
|
||||
# POSTGRES_TEST_DB=basic_memory_test
|
||||
|
||||
# ============================================================================
|
||||
# Production Database Configuration
|
||||
# ============================================================================
|
||||
# For production use, set these in your deployment environment
|
||||
# DO NOT use the test credentials above in production!
|
||||
|
||||
# BASIC_MEMORY_DATABASE_BACKEND=postgres # or "sqlite"
|
||||
# BASIC_MEMORY_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
|
||||
@@ -13,7 +13,8 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
test-sqlite:
|
||||
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -64,7 +65,49 @@ jobs:
|
||||
run: |
|
||||
just lint
|
||||
|
||||
- name: Run tests
|
||||
- name: Run tests (SQLite)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test
|
||||
just test-sqlite
|
||||
|
||||
test-postgres:
|
||||
name: Test Postgres (Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.12", "3.13" ]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Note: No services section needed - testcontainers handles Postgres in Docker
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- 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 tests (Postgres via testcontainers)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
+2
-1
@@ -52,4 +52,5 @@ ENV/
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
|
||||
+220
@@ -1,5 +1,225 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 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
|
||||
|
||||
- **#439**: Add PostgreSQL database backend support
|
||||
([`fb5e9e1`](https://github.com/basicmachines-co/basic-memory/commit/fb5e9e1))
|
||||
- Full PostgreSQL/Neon database support as alternative to SQLite
|
||||
- Async connection pooling with asyncpg
|
||||
- Alembic migrations support for both backends
|
||||
- Configurable via `BASIC_MEMORY_DATABASE_BACKEND` environment variable
|
||||
|
||||
- **#441**: Implement API v2 with ID-based endpoints (Phase 1)
|
||||
([`28cc522`](https://github.com/basicmachines-co/basic-memory/commit/28cc522))
|
||||
- New ID-based API endpoints for improved performance
|
||||
- Foundation for future API enhancements
|
||||
- Backward compatible with existing endpoints
|
||||
|
||||
- Add project_id to Relation and Observation for efficient project-scoped queries
|
||||
([`a920a9f`](https://github.com/basicmachines-co/basic-memory/commit/a920a9f))
|
||||
- Enables faster queries in multi-project environments
|
||||
- Improved database schema for cloud deployments
|
||||
|
||||
- Add bulk insert with ON CONFLICT handling for relations
|
||||
([`0818bda`](https://github.com/basicmachines-co/basic-memory/commit/0818bda))
|
||||
- Faster relation creation during sync operations
|
||||
- Handles duplicate relations gracefully
|
||||
|
||||
### Performance
|
||||
|
||||
- Lightweight permalink resolution to avoid eager loading
|
||||
([`6f99d2e`](https://github.com/basicmachines-co/basic-memory/commit/6f99d2e))
|
||||
- Reduces database queries during entity lookups
|
||||
- Improved response times for read operations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#464**: Pin FastMCP to 2.12.3 to fix MCP tools visibility
|
||||
([`f227ef6`](https://github.com/basicmachines-co/basic-memory/commit/f227ef6))
|
||||
- Fixes issue where MCP tools were not visible to Claude
|
||||
- Reverts to last known working FastMCP version
|
||||
|
||||
- **#458**: Reduce watch service CPU usage by increasing reload interval
|
||||
([`897b1ed`](https://github.com/basicmachines-co/basic-memory/commit/897b1ed))
|
||||
- Lowers CPU usage during file watching
|
||||
- More efficient resource utilization
|
||||
|
||||
- **#456**: Await background sync task cancellation in lifespan shutdown
|
||||
([`efbc758`](https://github.com/basicmachines-co/basic-memory/commit/efbc758))
|
||||
- Prevents hanging on shutdown
|
||||
- Clean async task cleanup
|
||||
|
||||
- **#434**: Respect --project flag in background sync
|
||||
([`70bb10b`](https://github.com/basicmachines-co/basic-memory/commit/70bb10b))
|
||||
- Background sync now correctly uses specified project
|
||||
- Fixes multi-project sync issues
|
||||
|
||||
- **#446**: Fix observation parsing and permalink limits
|
||||
([`73d940e`](https://github.com/basicmachines-co/basic-memory/commit/73d940e))
|
||||
- Handles edge cases in observation content
|
||||
- Prevents permalink truncation issues
|
||||
|
||||
- **#424**: Handle periods in kebab_filenames mode
|
||||
([`b004565`](https://github.com/basicmachines-co/basic-memory/commit/b004565))
|
||||
- Fixes filename handling for files with multiple periods
|
||||
- Improved kebab-case conversion
|
||||
|
||||
- Fix Postgres/Neon connection settings and search index dedupe
|
||||
([`b5d4fb5`](https://github.com/basicmachines-co/basic-memory/commit/b5d4fb5))
|
||||
- Optimized connection pooling for Postgres
|
||||
- Prevents duplicate search index entries
|
||||
|
||||
### Testing & CI
|
||||
|
||||
- Replace py-pglite with testcontainers for Postgres testing
|
||||
([`c462faf`](https://github.com/basicmachines-co/basic-memory/commit/c462faf))
|
||||
- More reliable Postgres testing infrastructure
|
||||
- Uses Docker-based test containers
|
||||
|
||||
- Add PostgreSQL testing to GitHub Actions workflow
|
||||
([`66b91b2`](https://github.com/basicmachines-co/basic-memory/commit/66b91b2))
|
||||
- CI now tests both SQLite and PostgreSQL backends
|
||||
- Ensures cross-database compatibility
|
||||
|
||||
- **#416**: Add integration test for read_note with underscored folders
|
||||
([`0c12a39`](https://github.com/basicmachines-co/basic-memory/commit/0c12a39))
|
||||
- Verifies folder name handling edge cases
|
||||
|
||||
### Internal
|
||||
|
||||
- Cloud compatibility fixes and performance improvements (#454)
|
||||
- Remove logfire instrumentation for cleaner production deployments
|
||||
- Truncate content_stems to fix Postgres 8KB index row limit
|
||||
|
||||
## v0.16.2 (2025-11-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#429**: Use platform-native path separators in config.json
|
||||
([`6517e98`](https://github.com/basicmachines-co/basic-memory/commit/6517e98))
|
||||
- Fixes config.json path separator issues on Windows
|
||||
- Uses os.path.join for platform-native path construction
|
||||
- Ensures consistent path handling across platforms
|
||||
|
||||
- **#427**: Add rclone installation checks for Windows bisync commands
|
||||
([`1af0539`](https://github.com/basicmachines-co/basic-memory/commit/1af0539))
|
||||
- Validates rclone installation before running bisync commands
|
||||
- Provides clear error messages when rclone is not installed
|
||||
- Improves user experience on Windows
|
||||
|
||||
- **#421**: Main project always recreated on project list command
|
||||
([`cad7019`](https://github.com/basicmachines-co/basic-memory/commit/cad7019))
|
||||
- Fixes issue where main project was recreated unnecessarily
|
||||
- Improves project list command reliability
|
||||
- Reduces unnecessary file system operations
|
||||
|
||||
## v0.16.1 (2025-11-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -15,10 +15,14 @@ See the [README.md](README.md) file for a project overview.
|
||||
### Build and Test Commands
|
||||
|
||||
- Install: `just install` or `pip install -e ".[dev]"`
|
||||
- Run all tests (with coverage): `just test` - Runs both unit and integration tests with unified coverage
|
||||
- Run unit tests only: `just test-unit` - Fast, no coverage
|
||||
- Run integration tests only: `just test-int` - Fast, no coverage
|
||||
- Generate HTML coverage: `just coverage` - Opens in browser
|
||||
- Run all tests (SQLite + Postgres): `just test`
|
||||
- Run all tests against SQLite: `just test-sqlite`
|
||||
- Run all tests against Postgres: `just test-postgres` (uses testcontainers)
|
||||
- Run unit tests (SQLite): `just test-unit-sqlite`
|
||||
- Run unit tests (Postgres): `just test-unit-postgres`
|
||||
- Run integration tests (SQLite): `just test-int-sqlite`
|
||||
- Run integration tests (Postgres): `just test-int-postgres`
|
||||
- Generate HTML coverage: `just coverage`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
|
||||
- Lint: `just lint` or `ruff check . --fix`
|
||||
@@ -30,6 +34,8 @@ See the [README.md](README.md) file for a project overview.
|
||||
|
||||
**Note:** Project requires Python 3.12+ (uses type parameter syntax and `type` aliases introduced in 3.12)
|
||||
|
||||
**Postgres Testing:** Uses [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- `tests/` - Unit tests for individual components (mocked, fast)
|
||||
@@ -52,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
|
||||
@@ -76,8 +140,10 @@ See the [README.md](README.md) file for a project overview.
|
||||
- SQLite is used for indexing and full text search, files are source of truth
|
||||
- Testing uses pytest with asyncio support (strict mode)
|
||||
- Unit tests (`tests/`) use mocks when necessary; integration tests (`test-int/`) use real implementations
|
||||
- Test database uses in-memory SQLite
|
||||
- Each test runs in a standalone environment with in-memory SQLite and tmp_file directory
|
||||
- By default, tests run against SQLite (fast, no Docker needed)
|
||||
- Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required)
|
||||
- Each test runs in a standalone environment with isolated database and tmp_path directory
|
||||
- CI runs SQLite and Postgres tests in parallel for faster feedback
|
||||
- Performance benchmarks are in `test-int/test_sync_performance_benchmark.py`
|
||||
- Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
|
||||
@@ -132,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
|
||||
|
||||
@@ -174,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+)
|
||||
|
||||
@@ -229,6 +300,11 @@ of using AI just for code generation, we've developed a true collaborative workf
|
||||
This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI
|
||||
could achieve independently.
|
||||
|
||||
**Problem-Solving Guidance:**
|
||||
- If a solution isn't working after reasonable effort, suggest alternative approaches
|
||||
- Don't persist with a problematic library or pattern when better alternatives exist
|
||||
- Example: When py-pglite caused cascading test failures, switching to testcontainers-postgres was the right call
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub:
|
||||
@@ -264,5 +340,6 @@ With GitHub integration, the development workflow includes:
|
||||
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
|
||||
3. **Branch management** - Claude can create feature branches for implementations
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
|
||||
|
||||
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
|
||||
|
||||
@@ -433,6 +433,124 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
|
||||
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
|
||||
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
|
||||
|
||||
## Logging
|
||||
|
||||
Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The logging behavior varies by entry point:
|
||||
|
||||
| Entry Point | Default Behavior | Use Case |
|
||||
|-------------|------------------|----------|
|
||||
| CLI commands | File only | Prevents log output from interfering with command output |
|
||||
| MCP server | File only | Stdout would corrupt the JSON-RPC protocol |
|
||||
| API server | File (local) or stdout (cloud) | Docker/cloud deployments use stdout |
|
||||
|
||||
**Log file location:** `~/.basic-memory/basic-memory.log` (10MB rotation, 10 days retention)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
|
||||
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
|
||||
|
||||
# View logs
|
||||
tail -f ~/.basic-memory/basic-memory.log
|
||||
|
||||
# Cloud/Docker mode (stdout logging with structured context)
|
||||
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Basic Memory supports dual database backends (SQLite and Postgres). By default, tests run against SQLite. Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required).
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
# Run all tests against SQLite (default, fast)
|
||||
just test-sqlite
|
||||
|
||||
# Run all tests against Postgres (uses testcontainers)
|
||||
just test-postgres
|
||||
|
||||
# Run both SQLite and Postgres tests
|
||||
just test
|
||||
```
|
||||
|
||||
**Available Test Commands:**
|
||||
|
||||
- `just test` - Run all tests against both SQLite and Postgres
|
||||
- `just test-sqlite` - Run all tests against SQLite (fast, no Docker needed)
|
||||
- `just test-postgres` - Run all tests against Postgres (uses testcontainers)
|
||||
- `just test-unit-sqlite` - Run unit tests against SQLite
|
||||
- `just test-unit-postgres` - Run unit tests against Postgres
|
||||
- `just test-int-sqlite` - Run integration tests against SQLite
|
||||
- `just test-int-postgres` - Run integration tests against Postgres
|
||||
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
|
||||
- `just test-benchmark` - Run performance benchmark tests
|
||||
|
||||
**Postgres Testing:**
|
||||
|
||||
Postgres tests use [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
|
||||
|
||||
**Test Markers:**
|
||||
|
||||
Tests use pytest markers for selective execution:
|
||||
- `windows` - Windows-specific database optimizations
|
||||
- `benchmark` - Performance tests (excluded from default runs)
|
||||
|
||||
**Other Development Commands:**
|
||||
```bash
|
||||
just install # Install with dev dependencies
|
||||
just lint # Run linting checks
|
||||
just typecheck # Run type checking
|
||||
just format # Format code with ruff
|
||||
just check # Run all quality checks
|
||||
just migration "msg" # Create database migration
|
||||
```
|
||||
|
||||
See the [justfile](justfile) for the complete list of development commands.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Docker Compose configuration for Basic Memory with PostgreSQL
|
||||
# Use this for local development and testing with Postgres backend
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
# docker-compose -f docker-compose-postgres.yml down
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: basic-memory-postgres
|
||||
environment:
|
||||
# Local development/test credentials - NOT for production
|
||||
# These values are referenced by tests and justfile commands
|
||||
POSTGRES_DB: basic_memory
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password # Simple password for local testing only
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Named volume for Postgres data
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
# Named volume for persistent configuration
|
||||
# Database will be stored in Postgres, not in this volume
|
||||
basic-memory-config:
|
||||
driver: local
|
||||
|
||||
# Network configuration (optional)
|
||||
# networks:
|
||||
# basic-memory-net:
|
||||
# driver: bridge
|
||||
@@ -7,16 +7,94 @@ install:
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
# Run unit tests only (fast, no coverage)
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto tests
|
||||
# ==============================================================================
|
||||
# DATABASE BACKEND TESTING
|
||||
# ==============================================================================
|
||||
# Basic Memory supports dual database backends (SQLite and Postgres).
|
||||
# By default, tests run against SQLite (fast, no dependencies).
|
||||
# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers).
|
||||
#
|
||||
# Quick Start:
|
||||
# just test # Run all tests against SQLite (default)
|
||||
# just test-sqlite # Run all tests against SQLite
|
||||
# just test-postgres # Run all tests against Postgres (testcontainers)
|
||||
# just test-unit-sqlite # Run unit tests against SQLite
|
||||
# just test-unit-postgres # Run unit tests against Postgres
|
||||
# just test-int-sqlite # Run integration tests against SQLite
|
||||
# just test-int-postgres # Run integration tests against Postgres
|
||||
#
|
||||
# CI runs both in parallel for faster feedback.
|
||||
# ==============================================================================
|
||||
|
||||
# Run integration tests only (fast, no coverage)
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto test-int
|
||||
# Run all tests against SQLite and Postgres
|
||||
test: test-sqlite test-postgres
|
||||
|
||||
# Run all tests with unified coverage report
|
||||
test: test-unit test-int
|
||||
# Run all tests against SQLite
|
||||
test-sqlite: test-unit-sqlite test-int-sqlite
|
||||
|
||||
# Run all tests against Postgres (uses testcontainers)
|
||||
test-postgres: test-unit-postgres test-int-postgres
|
||||
|
||||
# Run unit tests against SQLite
|
||||
test-unit-sqlite:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run unit tests against Postgres
|
||||
test-unit-postgres:
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests against SQLite
|
||||
test-int-sqlite:
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
|
||||
# Run integration tests against Postgres
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
# See: https://github.com/jlowin/fastmcp/issues/1311
|
||||
test-int-postgres:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
|
||||
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
|
||||
if [[ -n "$TIMEOUT_CMD" ]]; then
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_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
|
||||
# Uses credentials from docker-compose-postgres.yml
|
||||
postgres-reset:
|
||||
docker exec basic-memory-postgres psql -U ${POSTGRES_USER:-basic_memory_user} -d ${POSTGRES_TEST_DB:-basic_memory_test} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
@echo "✅ Postgres test database reset"
|
||||
|
||||
# Run Alembic migrations manually against Postgres test database
|
||||
# Useful for debugging migration issues
|
||||
# Uses credentials from docker-compose-postgres.yml (can override with env vars)
|
||||
postgres-migrate:
|
||||
@cd src/basic_memory/alembic && \
|
||||
BASIC_MEMORY_DATABASE_BACKEND=postgres \
|
||||
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
|
||||
uv run alembic upgrade head
|
||||
@echo "✅ Migrations applied to Postgres test database"
|
||||
|
||||
# Run Windows-specific tests only (only works on Windows platform)
|
||||
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
|
||||
# Will be skipped automatically on non-Windows platforms
|
||||
test-windows:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
|
||||
# Run benchmark tests only (performance testing)
|
||||
# These are slow tests that measure sync performance with various file counts
|
||||
# Excluded from default test runs to keep CI fast
|
||||
test-benchmark:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
|
||||
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
|
||||
# Use this before releasing to ensure everything works across all backends and platforms
|
||||
test-all:
|
||||
uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
@@ -104,8 +182,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..."
|
||||
|
||||
+14
-4
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.1.1",
|
||||
"pydantic[email,timezone]>=2.10.3",
|
||||
"icecream>=2.1.3",
|
||||
"mcp>=1.2.0",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"loguru>=0.7.3",
|
||||
@@ -30,12 +29,19 @@ dependencies = [
|
||||
"alembic>=1.14.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=2.10.2",
|
||||
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
"logfire>=0.73.0", # Optional observability (disabled by default via config)
|
||||
"aiofiles>=24.1.0", # Optional observability (disabled by default via config)
|
||||
"asyncpg>=0.30.0",
|
||||
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"psycopg==3.3.1",
|
||||
"mdformat>=0.7.22",
|
||||
"mdformat-gfm>=0.3.7",
|
||||
"mdformat-frontmatter>=2.0.8",
|
||||
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
|
||||
]
|
||||
|
||||
|
||||
@@ -61,6 +67,8 @@ asyncio_default_fixture_loop_scope = "function"
|
||||
markers = [
|
||||
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
|
||||
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
|
||||
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
|
||||
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@@ -78,6 +86,8 @@ dev = [
|
||||
"pytest-xdist>=3.0.0",
|
||||
"ruff>=0.1.6",
|
||||
"freezegun>=1.5.5",
|
||||
"testcontainers[postgres]>=4.0.0",
|
||||
"psycopg>=3.2.0",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.16.1"
|
||||
__version__ = "0.17.2"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
+110
-24
@@ -1,17 +1,32 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
|
||||
# Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
|
||||
try:
|
||||
import nest_asyncio
|
||||
|
||||
nest_asyncio.apply()
|
||||
except (ImportError, ValueError):
|
||||
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
|
||||
pass
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from alembic import context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
# Trigger: only set test env when actually running under pytest
|
||||
# Why: alembic/env.py is imported during normal operations (MCP server startup, migrations)
|
||||
# but we only want test behavior during actual test runs
|
||||
# Outcome: prevents is_test_env from returning True in production, enabling watch service
|
||||
if os.getenv("PYTEST_CURRENT_TEST") is not None:
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
|
||||
# Import after setting environment variable # noqa: E402
|
||||
from basic_memory.models import Base # noqa: E402
|
||||
@@ -20,12 +35,22 @@ from basic_memory.models import Base # noqa: E402
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
|
||||
# due to Pydantic's env_prefix="BASIC_MEMORY_" setting
|
||||
app_config = ConfigManager().config
|
||||
# Set the SQLAlchemy URL from our app config
|
||||
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
|
||||
# Set the SQLAlchemy URL based on database backend configuration
|
||||
# If the URL is already set in config (e.g., from run_migrations), use that
|
||||
# Otherwise, get it from app config
|
||||
# Note: alembic.ini has a placeholder URL "driver://user:pass@localhost/dbname" that we need to override
|
||||
current_url = config.get_main_option("sqlalchemy.url")
|
||||
if not current_url or current_url == "driver://user:pass@localhost/dbname":
|
||||
from basic_memory.db import DatabaseType
|
||||
|
||||
sqlalchemy_url = DatabaseType.get_db_url(
|
||||
app_config.database_path, DatabaseType.FILESYSTEM, app_config
|
||||
)
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
@@ -69,28 +94,89 @@ def run_migrations_offline() -> None:
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
"""Execute migrations with the given connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations(connectable):
|
||||
"""Run migrations asynchronously with AsyncEngine."""
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
# Check if a connection/engine was provided (e.g., from run_migrations)
|
||||
connectable = context.config.attributes.get("connection", None)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
)
|
||||
if connectable is None:
|
||||
# No connection provided, create engine from config
|
||||
url = context.config.get_main_option("sqlalchemy.url")
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
|
||||
if url and ("+asyncpg" in url or "+aiosqlite" in url):
|
||||
# Create async engine for asyncpg or aiosqlite
|
||||
connectable = create_async_engine(
|
||||
url,
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
else:
|
||||
# Create sync engine for regular sqlite or postgresql
|
||||
connectable = engine_from_config(
|
||||
context.config.get_section(context.config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
# Handle async engines (PostgreSQL with asyncpg)
|
||||
if isinstance(connectable, AsyncEngine):
|
||||
# Try to run async migrations
|
||||
# nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
|
||||
try:
|
||||
asyncio.run(run_async_migrations(connectable))
|
||||
except RuntimeError as e:
|
||||
if "cannot be called from a running event loop" in str(e):
|
||||
# We're in a running event loop (likely uvloop) - need to use a different approach
|
||||
# Create a new thread to run the async migrations
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
"""Run async migrations in a new event loop in a separate thread."""
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
new_loop.run_until_complete(run_async_migrations(connectable))
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
future.result() # Wait for completion and re-raise any exceptions
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# Handle sync engines (SQLite) or sync connections
|
||||
if hasattr(connectable, "connect"):
|
||||
# It's an engine, get a connection
|
||||
with connectable.connect() as connection:
|
||||
do_run_migrations(connection)
|
||||
else:
|
||||
# It's already a connection
|
||||
do_run_migrations(connectable)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"""Add Postgres full-text search support with tsvector and GIN indexes
|
||||
|
||||
Revision ID: 314f1ea54dc4
|
||||
Revises: e7e1f4367280
|
||||
Create Date: 2025-11-15 18:05:01.025405
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "314f1ea54dc4"
|
||||
down_revision: Union[str, None] = "e7e1f4367280"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add PostgreSQL full-text search support.
|
||||
|
||||
This migration:
|
||||
1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
|
||||
2. Adds generated tsvector column for full-text search
|
||||
3. Creates GIN index on the tsvector column for fast text queries
|
||||
4. Creates GIN index on metadata JSONB column for fast containment queries
|
||||
|
||||
Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
|
||||
"""
|
||||
# Check if we're using Postgres
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Create search_index table for Postgres
|
||||
# For SQLite, this is a FTS5 virtual table created elsewhere
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
op.create_table(
|
||||
"search_index",
|
||||
sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
|
||||
sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("content_stems", sa.Text(), nullable=True),
|
||||
sa.Column("content_snippet", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
|
||||
sa.Column("file_path", sa.String(), nullable=True),
|
||||
sa.Column("type", sa.String(), nullable=True),
|
||||
sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("relation_type", sa.String(), nullable=True),
|
||||
sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
|
||||
sa.Column("category", sa.String(), nullable=True),
|
||||
sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", "type", "project_id"
|
||||
), # Composite key: id can repeat across types
|
||||
sa.ForeignKeyConstraint(
|
||||
["project_id"],
|
||||
["project.id"],
|
||||
name="fk_search_index_project_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# Create index on project_id for efficient multi-tenant queries
|
||||
op.create_index(
|
||||
"ix_search_index_project_id",
|
||||
"search_index",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Create unique partial index on permalink for markdown files
|
||||
# Non-markdown files don't have permalinks, so we use a partial index
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uix_search_index_permalink_project
|
||||
ON search_index (permalink, project_id)
|
||||
WHERE permalink IS NOT NULL
|
||||
""")
|
||||
|
||||
# Add tsvector column as a GENERATED ALWAYS column
|
||||
# This automatically updates when title or content_stems change
|
||||
op.execute("""
|
||||
ALTER TABLE search_index
|
||||
ADD COLUMN textsearchable_index_col tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('english',
|
||||
coalesce(title, '') || ' ' ||
|
||||
coalesce(content_stems, '')
|
||||
)
|
||||
) STORED
|
||||
""")
|
||||
|
||||
# Create GIN index on tsvector column for fast full-text search
|
||||
op.create_index(
|
||||
"idx_search_index_fts",
|
||||
"search_index",
|
||||
["textsearchable_index_col"],
|
||||
unique=False,
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
# Create GIN index on metadata JSONB for fast containment queries
|
||||
# Using jsonb_path_ops for smaller index size and better performance
|
||||
op.execute("""
|
||||
CREATE INDEX idx_search_index_metadata_gin
|
||||
ON search_index
|
||||
USING GIN (metadata jsonb_path_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove PostgreSQL full-text search support."""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Drop indexes first
|
||||
op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
|
||||
op.drop_index("idx_search_index_fts", table_name="search_index")
|
||||
op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
|
||||
op.drop_index("ix_search_index_project_id", table_name="search_index")
|
||||
|
||||
# Drop the generated column
|
||||
op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
|
||||
|
||||
# Drop the search_index table
|
||||
op.drop_table("search_index")
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
|
||||
# SQLite FTS5 virtual table handling is SQLite-specific
|
||||
# For Postgres, search_index is a regular table managed by ORM
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
op.create_table(
|
||||
"project",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
@@ -55,7 +61,9 @@ def upgrade() -> None:
|
||||
batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
|
||||
batch_op.drop_index(
|
||||
"uix_entity_permalink",
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.drop_index("ix_entity_file_path")
|
||||
batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
|
||||
@@ -67,12 +75,16 @@ def upgrade() -> None:
|
||||
"uix_entity_permalink_project",
|
||||
["permalink", "project_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
|
||||
|
||||
# drop the search index table. it will be recreated
|
||||
op.drop_table("search_index")
|
||||
# Only drop for SQLite - Postgres uses regular table managed by ORM
|
||||
if is_sqlite:
|
||||
op.drop_table("search_index")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -25,43 +25,51 @@ def upgrade() -> None:
|
||||
The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
|
||||
which breaks project creation when the service sets is_default=False.
|
||||
|
||||
Since SQLite doesn't support dropping specific constraints easily, we'll
|
||||
recreate the table without the problematic constraint.
|
||||
SQLite: Recreate the table without the constraint (no ALTER TABLE support)
|
||||
Postgres: Use ALTER TABLE to drop the constraint directly
|
||||
"""
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
if is_sqlite:
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
else:
|
||||
# For Postgres, we can simply drop the constraint
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("project_is_default_key", type_="unique")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Add cascade delete FK from search_index to entity
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: f8a9b2c3d4e5
|
||||
Create Date: 2025-12-02 07:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2b3c4d5e6f7"
|
||||
down_revision: Union[str, None] = "f8a9b2c3d4e5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add FK with CASCADE delete from search_index.entity_id to entity.id.
|
||||
|
||||
This migration is Postgres-only because:
|
||||
- SQLite uses FTS5 virtual tables which don't support foreign keys
|
||||
- The FK enables automatic cleanup of search_index entries when entities are deleted
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# First, clean up any orphaned search_index entries where entity no longer exists
|
||||
op.execute("""
|
||||
DELETE FROM search_index
|
||||
WHERE entity_id IS NOT NULL
|
||||
AND entity_id NOT IN (SELECT id FROM entity)
|
||||
""")
|
||||
|
||||
# Add FK with CASCADE - nullable FK allows search_index entries without entity_id
|
||||
op.create_foreign_key(
|
||||
"fk_search_index_entity_id",
|
||||
"search_index",
|
||||
"entity",
|
||||
["entity_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove the FK constraint."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.drop_constraint("fk_search_index_entity_id", "search_index", type_="foreignkey")
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Upgrade database schema to use new search index with content_stems and content_snippet."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is created via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# First, drop the existing search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
@@ -59,6 +65,13 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade database schema to use old search index."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is managed via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# Drop the updated search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"""Add project_id to relation/observation and pg_trgm for fuzzy link resolution
|
||||
|
||||
Revision ID: f8a9b2c3d4e5
|
||||
Revises: 314f1ea54dc4
|
||||
Create Date: 2025-12-01 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f8a9b2c3d4e5"
|
||||
down_revision: Union[str, None] = "314f1ea54dc4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add project_id to relation and observation tables, plus pg_trgm indexes.
|
||||
|
||||
This migration:
|
||||
1. Adds project_id column to relation and observation tables (denormalization)
|
||||
2. Backfills project_id from the associated entity
|
||||
3. Enables pg_trgm extension for trigram-based fuzzy matching (Postgres only)
|
||||
4. Creates GIN indexes on entity title and permalink for fast similarity searches
|
||||
5. Creates partial index on unresolved relations for efficient bulk resolution
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add project_id to relation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "relation", "project_id"):
|
||||
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via from_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE relation.from_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = relation.from_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("relation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"relation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on relation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_relation_project_id"):
|
||||
op.create_index("ix_relation_project_id", "relation", ["project_id"])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add project_id to observation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "observation", "project_id"):
|
||||
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via entity_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE observation.entity_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = observation.entity_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("observation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"observation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on observation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_observation_project_id"):
|
||||
op.create_index("ix_observation_project_id", "observation", ["project_id"])
|
||||
|
||||
# Postgres-specific: pg_trgm and GIN indexes
|
||||
if dialect == "postgresql":
|
||||
# Enable pg_trgm extension for fuzzy string matching
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
|
||||
# Create trigram indexes on entity table for fuzzy matching
|
||||
# GIN indexes with gin_trgm_ops support similarity searches
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_title_trgm
|
||||
ON entity USING gin (title gin_trgm_ops)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_permalink_trgm
|
||||
ON entity USING gin (permalink gin_trgm_ops)
|
||||
""")
|
||||
|
||||
# Create partial index on unresolved relations for efficient bulk resolution
|
||||
# This makes "WHERE to_id IS NULL AND project_id = X" queries very fast
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_unresolved
|
||||
ON relation (project_id, to_name)
|
||||
WHERE to_id IS NULL
|
||||
""")
|
||||
|
||||
# Create index on relation.to_name for join performance in bulk resolution
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_to_name
|
||||
ON relation (to_name)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove project_id from relation/observation and pg_trgm indexes."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Drop Postgres-specific indexes
|
||||
op.execute("DROP INDEX IF EXISTS idx_relation_to_name")
|
||||
op.execute("DROP INDEX IF EXISTS idx_relation_unresolved")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_permalink_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_title_trgm")
|
||||
# Note: We don't drop the pg_trgm extension as other code may depend on it
|
||||
|
||||
# Drop project_id from observation
|
||||
op.drop_index("ix_observation_project_id", table_name="observation")
|
||||
op.drop_constraint("fk_observation_project_id", "observation", type_="foreignkey")
|
||||
op.drop_column("observation", "project_id")
|
||||
|
||||
# Drop project_id from relation
|
||||
op.drop_index("ix_relation_project_id", table_name="relation")
|
||||
op.drop_constraint("fk_relation_project_id", "relation", type_="foreignkey")
|
||||
op.drop_column("relation", "project_id")
|
||||
else:
|
||||
# SQLite requires batch operations
|
||||
op.drop_index("ix_observation_project_id", table_name="observation")
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.drop_constraint("fk_observation_project_id", type_="foreignkey")
|
||||
batch_op.drop_column("project_id")
|
||||
|
||||
op.drop_index("ix_relation_project_id", table_name="relation")
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.drop_constraint("fk_relation_project_id", type_="foreignkey")
|
||||
batch_op.drop_column("project_id")
|
||||
+43
-10
@@ -20,7 +20,17 @@ from basic_memory.api.routers import (
|
||||
search,
|
||||
prompt_router,
|
||||
)
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router as v2_knowledge,
|
||||
project_router as v2_project,
|
||||
memory_router as v2_memory,
|
||||
search_router as v2_search,
|
||||
resource_router as v2_resource,
|
||||
directory_router as v2_directory,
|
||||
prompt_router as v2_prompt,
|
||||
importer_router as v2_importer,
|
||||
)
|
||||
from basic_memory.config import ConfigManager, init_api_logging
|
||||
from basic_memory.services.initialization import initialize_file_sync, initialize_app
|
||||
|
||||
|
||||
@@ -28,6 +38,9 @@ from basic_memory.services.initialization import initialize_file_sync, initializ
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
|
||||
|
||||
# Initialize logging for API (stdout in cloud mode, file otherwise)
|
||||
init_api_logging()
|
||||
|
||||
app_config = ConfigManager().config
|
||||
logger.info("Starting Basic Memory API")
|
||||
|
||||
@@ -40,12 +53,21 @@ 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
|
||||
yield
|
||||
@@ -54,6 +76,10 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
if app.state.sync_task:
|
||||
logger.info("Stopping sync...")
|
||||
app.state.sync_task.cancel() # pyright: ignore
|
||||
try:
|
||||
await app.state.sync_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Sync task cancelled successfully")
|
||||
|
||||
await db.shutdown_db()
|
||||
|
||||
@@ -66,8 +92,7 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
# Include v1 routers
|
||||
app.include_router(knowledge.router, prefix="/{project}")
|
||||
app.include_router(memory.router, prefix="/{project}")
|
||||
app.include_router(resource.router, prefix="/{project}")
|
||||
@@ -77,12 +102,20 @@ 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 accross projects
|
||||
# Include v2 routers (ID-based paths)
|
||||
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Project resource router works across projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
|
||||
# Auth routes are handled by FastMCP automatically when auth is enabled
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(request, exc): # pragma: no cover
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Router for knowledge graph operations."""
|
||||
"""Router for knowledge graph operations.
|
||||
|
||||
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
|
||||
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
|
||||
of path-based identifiers for improved performance and stability.
|
||||
|
||||
Migration guide: See docs/migration/v1-to-v2.md
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
@@ -25,7 +32,11 @@ from basic_memory.schemas import (
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
router = APIRouter(
|
||||
prefix="/knowledge",
|
||||
tags=["knowledge"],
|
||||
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
|
||||
)
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
|
||||
@@ -50,6 +50,7 @@ async def get_project(
|
||||
) # pragma: no cover
|
||||
|
||||
return ProjectItem(
|
||||
id=found_project.id,
|
||||
name=found_project.name,
|
||||
path=normalize_project_path(found_project.path),
|
||||
is_default=found_project.is_default or False,
|
||||
@@ -80,9 +81,17 @@ async def update_project(
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Project '{name}' not found in configuration"
|
||||
)
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
name=name,
|
||||
path=project_service.projects.get(name, ""),
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
if path:
|
||||
@@ -91,14 +100,21 @@ async def update_project(
|
||||
await project_service.update_project(name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_path = path if path else project_service.projects.get(name, "")
|
||||
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")
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' updated successfully",
|
||||
status="success",
|
||||
default=(name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(name=name, path=updated_path),
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.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))
|
||||
@@ -186,6 +202,7 @@ async def list_projects(
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
@@ -232,6 +249,7 @@ async def add_project(
|
||||
status="success",
|
||||
default=existing_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=existing_project.id,
|
||||
name=existing_project.name,
|
||||
path=existing_project.path,
|
||||
is_default=existing_project.is_default or False,
|
||||
@@ -250,12 +268,20 @@ async def add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
# Fetch the newly created project to get its ID
|
||||
new_project = await project_service.get_project(project_data.name)
|
||||
if not new_project:
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
name=project_data.name, path=project_data.path, is_default=project_data.set_default
|
||||
id=new_project.id,
|
||||
name=new_project.name,
|
||||
path=new_project.path,
|
||||
is_default=new_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
@@ -303,10 +329,15 @@ 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(name=old_project.name, path=old_project.path),
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
@@ -349,8 +380,14 @@ async def set_default_project(
|
||||
message=f"Project '{name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(name=default_name, path=default_project.path),
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
@@ -378,7 +415,12 @@ async def get_default_project(
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
|
||||
return ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_project.name,
|
||||
path=default_project.path,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
|
||||
# Synchronize projects between config and database
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
@@ -25,6 +25,17 @@ from datetime import datetime
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def _mtime_to_datetime(entity: EntityModel) -> datetime:
|
||||
"""Convert entity mtime (file modification time) to 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()
|
||||
return entity.updated_at
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
@@ -39,7 +50,7 @@ def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
|
||||
@router.get("/{identifier:path}")
|
||||
@router.get("/{identifier:path}", response_model=None)
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
@@ -50,7 +61,7 @@ async def get_resource_content(
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> FileResponse:
|
||||
) -> Union[Response, FileResponse]:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
|
||||
@@ -81,13 +92,16 @@ async def get_resource_content(
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
entity = results[0]
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
# Check file exists via file_service (for cloud compatibility)
|
||||
if not await file_service.exists(entity.file_path):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
# Read content via file_service as bytes (works with both local and S3)
|
||||
content = await file_service.read_file_bytes(entity.file_path)
|
||||
content_type = file_service.content_type(entity.file_path)
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
# for multiple files, initialize a temporary file for writing the results
|
||||
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
|
||||
@@ -97,7 +111,7 @@ async def get_resource_content(
|
||||
# Read content for each entity
|
||||
content = await file_service.read_entity_content(result)
|
||||
memory_url = normalize_memory_url(result.permalink)
|
||||
modified_date = result.updated_at.isoformat()
|
||||
modified_date = _mtime_to_datetime(result).isoformat()
|
||||
checksum = result.checksum[:8] if result.checksum else ""
|
||||
|
||||
# Prepare the delimited content
|
||||
@@ -171,21 +185,17 @@ async def write_resource(
|
||||
else:
|
||||
content_str = str(content)
|
||||
|
||||
# Get full file path
|
||||
full_path = Path(f"{config.home}/{file_path}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write content to file
|
||||
checksum = await file_service.write_file(full_path, content_str)
|
||||
# Cloud compatibility: do not assume a local filesystem path structure.
|
||||
# Delegate directory creation + writes to the configured FileService (local or S3).
|
||||
await file_service.ensure_directory(Path(file_path).parent)
|
||||
checksum = await file_service.write_file(file_path, content_str)
|
||||
|
||||
# Get file info
|
||||
file_stats = file_service.file_stats(full_path)
|
||||
file_metadata = await file_service.get_file_metadata(file_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(file_path).name
|
||||
content_type = file_service.content_type(full_path)
|
||||
content_type = file_service.content_type(file_path)
|
||||
|
||||
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
|
||||
|
||||
@@ -202,7 +212,7 @@ async def write_resource(
|
||||
"content_type": content_type,
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
"updated_at": file_metadata.modified_at,
|
||||
},
|
||||
)
|
||||
status_code = 200
|
||||
@@ -214,8 +224,8 @@ async def write_resource(
|
||||
content_type=content_type,
|
||||
file_path=file_path,
|
||||
checksum=checksum,
|
||||
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
||||
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
created_at=file_metadata.created_at,
|
||||
updated_at=file_metadata.modified_at,
|
||||
)
|
||||
entity = await entity_repository.add(entity)
|
||||
status_code = 201
|
||||
@@ -229,9 +239,9 @@ async def write_resource(
|
||||
content={
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"size": file_stats.st_size,
|
||||
"created_at": file_stats.st_ctime,
|
||||
"modified_at": file_stats.st_mtime,
|
||||
"size": file_metadata.size,
|
||||
"created_at": file_metadata.created_at.timestamp(),
|
||||
"modified_at": file_metadata.modified_at.timestamp(),
|
||||
},
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
|
||||
@@ -24,11 +24,30 @@ async def to_graph_context(
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
# First pass: collect all entity IDs needed for relations
|
||||
entity_ids_needed: set[int] = set()
|
||||
for context_item in context_result.results:
|
||||
for item in (
|
||||
[context_item.primary_result] + context_item.observations + context_item.related_results
|
||||
):
|
||||
if item.type == SearchItemType.RELATION:
|
||||
if item.from_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.from_id) # pyright: ignore
|
||||
if item.to_id:
|
||||
entity_ids_needed.add(item.to_id)
|
||||
|
||||
# Batch fetch all entities at once
|
||||
entity_lookup: dict[int, str] = {}
|
||||
if entity_ids_needed:
|
||||
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
|
||||
entity_lookup = {e.id: e.title for e in entities}
|
||||
|
||||
# Helper function to convert items to summaries
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
entity_id=item.id,
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
@@ -37,6 +56,8 @@ async def to_graph_context(
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
return ObservationSummary(
|
||||
observation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
@@ -45,15 +66,19 @@ async def to_graph_context(
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
|
||||
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
|
||||
from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_title = entity_lookup.get(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
relation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_entity.title if from_entity else None,
|
||||
to_entity=to_entity.title if to_entity else None,
|
||||
from_entity=from_title,
|
||||
from_entity_id=item.from_id, # pyright: ignore
|
||||
to_entity=to_title,
|
||||
to_entity_id=item.to_id,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
@@ -63,23 +88,19 @@ async def to_graph_context(
|
||||
hierarchical_results = []
|
||||
for context_item in context_result.results:
|
||||
# Process primary result
|
||||
primary_result = await to_summary(context_item.primary_result)
|
||||
primary_result = to_summary(context_item.primary_result)
|
||||
|
||||
# Process observations
|
||||
observations = []
|
||||
for obs in context_item.observations:
|
||||
observations.append(await to_summary(obs))
|
||||
# Process observations (always ObservationSummary, validated by context_service)
|
||||
observations = [to_summary(obs) for obs in context_item.observations]
|
||||
|
||||
# Process related results
|
||||
related = []
|
||||
for rel in context_item.related_results:
|
||||
related.append(await to_summary(rel))
|
||||
related = [to_summary(rel) for rel in context_item.related_results]
|
||||
|
||||
# Add to hierarchical results
|
||||
hierarchical_results.append(
|
||||
ContextResult(
|
||||
primary_result=primary_result,
|
||||
observations=observations,
|
||||
observations=observations, # pyright: ignore[reportArgumentType]
|
||||
related_results=related,
|
||||
)
|
||||
)
|
||||
@@ -111,6 +132,21 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
search_results = []
|
||||
for r in results:
|
||||
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
||||
|
||||
# Determine which IDs to set based on type
|
||||
entity_id = None
|
||||
observation_id = None
|
||||
relation_id = None
|
||||
|
||||
if r.type == SearchItemType.ENTITY:
|
||||
entity_id = r.id
|
||||
elif r.type == SearchItemType.OBSERVATION:
|
||||
observation_id = r.id
|
||||
entity_id = r.entity_id # Parent entity
|
||||
elif r.type == SearchItemType.RELATION:
|
||||
relation_id = r.id
|
||||
entity_id = r.entity_id # Parent entity
|
||||
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=r.title, # pyright: ignore
|
||||
@@ -121,6 +157,9 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
content=r.content,
|
||||
file_path=r.file_path,
|
||||
metadata=r.metadata,
|
||||
entity_id=entity_id,
|
||||
observation_id=observation_id,
|
||||
relation_id=relation_id,
|
||||
category=r.category,
|
||||
from_entity=entities[0].permalink if entities else None,
|
||||
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""API v2 module - ID-based entity references.
|
||||
|
||||
Version 2 of the Basic Memory API uses integer entity IDs as the primary
|
||||
identifier for improved performance and stability.
|
||||
|
||||
Key changes from v1:
|
||||
- Entity lookups use integer IDs instead of paths/permalinks
|
||||
- Direct database queries instead of cascading resolution
|
||||
- Stable references that don't change with file moves
|
||||
- Better caching support
|
||||
|
||||
All v2 routers are registered with the /v2 prefix.
|
||||
"""
|
||||
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router,
|
||||
memory_router,
|
||||
project_router,
|
||||
resource_router,
|
||||
search_router,
|
||||
directory_router,
|
||||
prompt_router,
|
||||
importer_router,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"memory_router",
|
||||
"project_router",
|
||||
"resource_router",
|
||||
"search_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""V2 API routers."""
|
||||
|
||||
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
|
||||
from basic_memory.api.v2.routers.project_router import router as project_router
|
||||
from basic_memory.api.v2.routers.memory_router import router as memory_router
|
||||
from basic_memory.api.v2.routers.search_router import router as search_router
|
||||
from basic_memory.api.v2.routers.resource_router import router as resource_router
|
||||
from basic_memory.api.v2.routers.directory_router import router as directory_router
|
||||
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
|
||||
from basic_memory.api.v2.routers.importer_router import router as importer_router
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"project_router",
|
||||
"memory_router",
|
||||
"search_router",
|
||||
"resource_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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.
|
||||
|
||||
Key improvements:
|
||||
- Direct project lookup via integer primary keys
|
||||
- Consistent with other v2 endpoints
|
||||
- Better performance through indexed queries
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from basic_memory.deps import DirectoryServiceV2Dep, ProjectIdPathDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory-v2"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_structure(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
):
|
||||
"""Get folder structure for navigation (no files).
|
||||
|
||||
Optimized endpoint for folder tree navigation. Returns only directory nodes
|
||||
without file metadata. For full tree with files, use /directory/tree.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
DirectoryNode tree containing only folders (type="directory")
|
||||
"""
|
||||
structure = await directory_service.get_directory_structure()
|
||||
return structure
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
@@ -0,0 +1,182 @@
|
||||
"""V2 Import Router - ID-based data import operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
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 basic_memory.deps import (
|
||||
ChatGPTImporterV2Dep,
|
||||
ClaudeConversationsImporterV2Dep,
|
||||
ClaudeProjectsImporterV2Dep,
|
||||
MemoryJsonImporterV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/import", tags=["import-v2"])
|
||||
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ChatGPTImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: ChatGPT importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeConversationsImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: Claude conversations importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeProjectsImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The Claude projects.json file.
|
||||
folder: The base folder to place the files in.
|
||||
importer: Claude projects importer instance.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude projects for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: MemoryJsonImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The memory.json file.
|
||||
folder: Optional destination folder within the project.
|
||||
importer: Memory JSON importer instance.
|
||||
|
||||
Returns:
|
||||
EntityImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing memory.json for project {project_id}")
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
file_str = file_bytes.decode("utf-8")
|
||||
for line in file_str.splitlines():
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
"""Helper function to import a file using an importer instance.
|
||||
|
||||
Args:
|
||||
importer: The importer instance to use
|
||||
file: The file to import
|
||||
destination_folder: Destination folder for imported content
|
||||
|
||||
Returns:
|
||||
Import result from the importer
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails
|
||||
"""
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,413 @@
|
||||
"""V2 Knowledge Router - 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.
|
||||
|
||||
Key improvements:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with file moves
|
||||
- Better performance through indexed queries
|
||||
- Simplified caching strategies
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityServiceV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
LinkResolverV2Dep,
|
||||
ProjectConfigV2Dep,
|
||||
AppConfigDep,
|
||||
SyncServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.schemas.request import EditEntityRequest
|
||||
from basic_memory.schemas.v2 import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
"""Background task to resolve relations for a specific entity.
|
||||
|
||||
This runs asynchronously after the API response is sent, preventing
|
||||
long delays when creating entities with many relations.
|
||||
"""
|
||||
try:
|
||||
# Only resolve relations for the newly created entity
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
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(
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
|
||||
## Resolution endpoint
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=EntityResolveResponse)
|
||||
async def resolve_identifier(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: EntityResolveRequest,
|
||||
link_resolver: LinkResolverV2Dep,
|
||||
) -> EntityResolveResponse:
|
||||
"""Resolve a string identifier (permalink, title, or path) to an entity ID.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Entity ID and metadata about how it was resolved
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if identifier cannot be resolved
|
||||
|
||||
Example:
|
||||
POST /v2/{project}/knowledge/resolve
|
||||
{"identifier": "specs/search"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"entity_id": 123,
|
||||
"permalink": "specs/search",
|
||||
"file_path": "specs/search.md",
|
||||
"title": "Search Specification",
|
||||
"resolution_method": "permalink"
|
||||
}
|
||||
"""
|
||||
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"Entity not found: '{data.identifier}'")
|
||||
|
||||
# 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"
|
||||
|
||||
result = EntityResolveResponse(
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: resolved '{data.identifier}' to entity_id={result.entity_id} via {resolution_method}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def get_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Get an entity by its numeric ID.
|
||||
|
||||
This is the primary entity retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Complete entity with observations and relations
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found
|
||||
"""
|
||||
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, title='{result.title}'")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Create endpoints
|
||||
|
||||
|
||||
@router.post("/entities", response_model=EntityResponseV2)
|
||||
async def create_entity(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
data: Entity data to create
|
||||
|
||||
Returns:
|
||||
Created entity with generated ID
|
||||
"""
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(data)
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
result = 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"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
## Update endpoints
|
||||
|
||||
|
||||
@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,
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by ID.
|
||||
|
||||
If the entity doesn't exist, it will be created (upsert behavior).
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
data: Updated entity data
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
"""
|
||||
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)
|
||||
created = existing is None
|
||||
|
||||
# Perform update or create
|
||||
entity, _ = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Schedule relation resolution for new entities
|
||||
if created:
|
||||
background_tasks.add_task(
|
||||
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}"
|
||||
)
|
||||
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,
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
data: Edit operation details
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 if edit fails
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
|
||||
)
|
||||
|
||||
# Verify entity exists
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
try:
|
||||
# Edit using the entity's permalink or path
|
||||
identifier = entity.permalink or entity.file_path
|
||||
updated_entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex
|
||||
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: entity_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity {entity_id}: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete endpoints
|
||||
|
||||
|
||||
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service=Depends(lambda: None), # Optional for now
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete an entity by ID.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Deletion status
|
||||
|
||||
Note: Returns deleted=False if entity doesn't exist (idempotent)
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: entity_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
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)
|
||||
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, deleted={deleted}")
|
||||
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
|
||||
|
||||
## Move endpoint
|
||||
|
||||
|
||||
@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,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> 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.
|
||||
|
||||
Args:
|
||||
project_id: Project ID from URL path
|
||||
entity_id: Entity ID from URL path (primary identifier)
|
||||
data: Move request with destination path only
|
||||
|
||||
Returns:
|
||||
Updated entity with new file path
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# First, get the entity by ID to verify it exists
|
||||
entity = await entity_repository.find_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
|
||||
|
||||
# Move the entity using its current file path as identifier
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=entity.file_path, # Use file path for resolution
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: moved entity_id={moved_entity.id} to '{data.destination_path}'"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,130 @@
|
||||
"""V2 routes for memory:// URI operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient 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 loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceV2Dep, EntityRepositoryV2Dep, ProjectIdPathDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
|
||||
@router.get("/memory/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity context for a project.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
type: Types of items to include (entities, relations, observations)
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Time window for recent activity (e.g., "7d", "1 week")
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include per item
|
||||
|
||||
Returns:
|
||||
GraphContext with recent activity and related entities
|
||||
"""
|
||||
# return all types by default
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not type
|
||||
else type
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
recent_context = await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
|
||||
return recent_context
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
|
||||
@router.get("/memory/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI.
|
||||
|
||||
V2 supports both legacy path-based URIs and new ID-based URIs:
|
||||
- Legacy: memory://path/to/note
|
||||
- ID-based: memory://id/123 or memory://123
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Optional time window for filtering related content
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include
|
||||
|
||||
Returns:
|
||||
GraphContext with the entity and its related context
|
||||
"""
|
||||
logger.debug(
|
||||
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""V2 Project Router - 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.
|
||||
|
||||
Key improvements:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with project renames
|
||||
- Better performance through indexed queries
|
||||
- Consistent with v2 entity operations
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectServiceDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
|
||||
from basic_memory.utils import normalize_project_path, generate_permalink
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=ProjectResolveResponse)
|
||||
async def resolve_project_identifier(
|
||||
data: ProjectResolveRequest,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectResolveResponse:
|
||||
"""Resolve a project identifier (name or permalink) to a project ID.
|
||||
|
||||
This endpoint provides efficient lookup of projects by name without
|
||||
needing to fetch the entire project list. Supports case-insensitive
|
||||
matching on both name and permalink.
|
||||
|
||||
Args:
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Project information including the numeric ID
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
POST /v2/projects/resolve
|
||||
{"identifier": "my-project"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"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)
|
||||
|
||||
# Try to find project by ID first (if identifier is numeric)
|
||||
resolution_method = "name"
|
||||
project = None
|
||||
|
||||
if data.identifier.isdigit():
|
||||
project_id = int(data.identifier)
|
||||
project = await project_repository.get_by_id(project_id)
|
||||
if project:
|
||||
resolution_method = "id"
|
||||
|
||||
# If not found by ID, try by permalink first (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
|
||||
# Uses efficient database query instead of fetching all projects
|
||||
if not project:
|
||||
project = await project_repository.get_by_name_case_insensitive(data.identifier)
|
||||
if project:
|
||||
resolution_method = "name"
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
||||
|
||||
return ProjectResolveResponse(
|
||||
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_id: ProjectIdPathDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectItem:
|
||||
"""Get project by its numeric ID.
|
||||
|
||||
This is the primary project retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
Project information
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
GET /v2/projects/3
|
||||
"""
|
||||
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
|
||||
|
||||
project = await project_repository.get_by_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
return ProjectItem(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def update_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
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.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails, 404 if project not found
|
||||
|
||||
Example:
|
||||
PATCH /v2/projects/3
|
||||
{"path": "/new/path"}
|
||||
"""
|
||||
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
|
||||
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_repository.get_by_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
# Update using project name (service layer still uses names internally)
|
||||
if path:
|
||||
await project_service.move_project(old_project.name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(old_project.name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_project = await project_repository.get_by_id(project_id)
|
||||
if not updated_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with 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),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.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))
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def delete_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
delete_notes: bool = Query(
|
||||
False, description="If True, delete project directory from filesystem"
|
||||
),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Delete a project by ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
Response confirming the project was deleted
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if trying to delete default project, 404 if not found
|
||||
|
||||
Example:
|
||||
DELETE /v2/projects/3?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)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
# Check if trying to delete the default project
|
||||
if old_project.name == project_service.default_project:
|
||||
available_projects = await project_service.list_projects()
|
||||
other_projects = [p.name for p in available_projects if p.id != project_id]
|
||||
detail = f"Cannot delete default project '{old_project.name}'. "
|
||||
if other_projects:
|
||||
detail += (
|
||||
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
||||
)
|
||||
else:
|
||||
detail += "This is the only project in your configuration."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
# Delete using project name (service layer still uses names internally)
|
||||
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{old_project.name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
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))
|
||||
|
||||
|
||||
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project by ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
PUT /v2/projects/3/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)
|
||||
if not default_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
# Get the new default project
|
||||
new_default_project = await project_repository.get_by_id(project_id)
|
||||
if not new_default_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
# Set as default using project name (service layer still uses names internally)
|
||||
await project_service.set_default_project(new_default_project.name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{new_default_project.name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.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))
|
||||
@@ -0,0 +1,270 @@
|
||||
"""V2 Prompt Router - ID-based prompt generation operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
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 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,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
SearchPromptRequest,
|
||||
PromptResponse,
|
||||
PromptMetadata,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
||||
|
||||
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,
|
||||
request: ContinueConversationRequest,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for continuing a conversation.
|
||||
|
||||
This endpoint takes a topic and/or timeframe and generates a prompt with
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
Formatted continuation prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"V2 Generating continue conversation prompt for project {project_id}, "
|
||||
f"topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
|
||||
# Get data needed for template
|
||||
if request.topic:
|
||||
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=request.search_items_limit)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
# Build context from results
|
||||
all_hierarchical_results = []
|
||||
for result in search_results:
|
||||
if hasattr(result, "permalink") and result.permalink:
|
||||
# Get hierarchical context using the new dataclass-based approach
|
||||
context_result = await context_service.build_context(
|
||||
result.permalink,
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True, # Include observations for entities
|
||||
)
|
||||
|
||||
# Process results into the schema format
|
||||
graph_context = await to_graph_context(
|
||||
context_result, entity_repository=entity_repository
|
||||
)
|
||||
|
||||
# Add results to our collection (limit to top results for each permalink)
|
||||
if graph_context.results:
|
||||
all_hierarchical_results.extend(graph_context.results[:3])
|
||||
|
||||
# Limit to a reasonable number of total results
|
||||
all_hierarchical_results = all_hierarchical_results[:10]
|
||||
|
||||
template_context = {
|
||||
"topic": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": all_hierarchical_results,
|
||||
"has_results": len(all_hierarchical_results) > 0,
|
||||
}
|
||||
else:
|
||||
# If no topic, get recent activity
|
||||
context_result = await context_service.build_context(
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True,
|
||||
)
|
||||
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
||||
|
||||
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
||||
|
||||
template_context = {
|
||||
"topic": f"Recent Activity from ({request.timeframe})",
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": hierarchical_results,
|
||||
"has_results": len(hierarchical_results) > 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", template_context
|
||||
)
|
||||
|
||||
# Calculate metadata
|
||||
# Count items of different types
|
||||
observation_count = 0
|
||||
relation_count = 0
|
||||
entity_count = 0
|
||||
|
||||
# Get the hierarchical results from the template context
|
||||
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
||||
|
||||
# For topic-based search
|
||||
if request.topic:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
# For recent activity
|
||||
else:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results)
|
||||
if request.topic
|
||||
else 0, # Original search results count
|
||||
"context_count": len(hierarchical_results_for_count),
|
||||
"observation_count": observation_count,
|
||||
"relation_count": relation_count,
|
||||
"total_items": (
|
||||
len(hierarchical_results_for_count)
|
||||
+ observation_count
|
||||
+ relation_count
|
||||
+ entity_count
|
||||
),
|
||||
"search_limit": request.search_items_limit,
|
||||
"context_depth": request.depth,
|
||||
"related_limit": request.related_items_limit,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering continue conversation template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
project_id: ProjectIdPathDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
request: SearchPromptRequest,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for search results.
|
||||
|
||||
This endpoint takes a search query and formats the results into a helpful
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
request: The search parameters
|
||||
page: The page number for pagination
|
||||
page_size: The number of results per page, defaults to 10
|
||||
|
||||
Returns:
|
||||
Formatted search results prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"V2 Generating search prompt for project {project_id}, "
|
||||
f"query: {request.query}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
template_context = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"results": search_results,
|
||||
"has_results": len(search_results) > 0,
|
||||
"result_count": len(search_results),
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results),
|
||||
"context_count": len(search_results),
|
||||
"observation_count": 0, # Search results don't include observations
|
||||
"relation_count": 0, # Search results don't include relations
|
||||
"total_items": len(search_results),
|
||||
"search_limit": limit,
|
||||
"context_depth": 0, # No context depth for basic search
|
||||
"related_limit": 0, # No related items for basic search
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering search template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""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.
|
||||
|
||||
Key differences from v1:
|
||||
- Uses integer entity IDs in URL paths instead of 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 fastapi import APIRouter, HTTPException, Response
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigV2Dep,
|
||||
EntityServiceV2Dep,
|
||||
FileServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
UpdateResourceRequest,
|
||||
ResourceResponse,
|
||||
)
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources-v2"])
|
||||
|
||||
|
||||
@router.get("/{entity_id}")
|
||||
async def get_resource_content(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
config: ProjectConfigV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> Response:
|
||||
"""Get raw resource content by entity ID.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
entity_id: Numeric entity ID
|
||||
config: Project configuration
|
||||
entity_service: Entity service for fetching entity data
|
||||
file_service: File service for reading file content
|
||||
|
||||
Returns:
|
||||
Response with entity content
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity or file not found
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
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(
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
|
||||
# Read content via file_service as bytes (works with both local and S3)
|
||||
content = await file_service.read_file_bytes(entity.file_path)
|
||||
content_type = file_service.content_type(entity.file_path)
|
||||
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
@router.post("", response_model=ResourceResponse)
|
||||
async def create_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: CreateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> ResourceResponse:
|
||||
"""Create a new resource file.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
data: Create resource request with file_path and content
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for creating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with file information including entity_id
|
||||
|
||||
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)
|
||||
if not validate_project_path(data.file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {data.file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {data.file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
# Check if entity already exists
|
||||
existing_entity = await entity_repository.get_by_file_path(data.file_path)
|
||||
if existing_entity:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
|
||||
f"Use PUT /resource/{existing_entity.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)
|
||||
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
|
||||
content_type = file_service.content_type(data.file_path)
|
||||
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Create a new entity model
|
||||
entity = EntityModel(
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
created_at=file_metadata.created_at,
|
||||
updated_at=file_metadata.modified_at,
|
||||
)
|
||||
entity = await entity_repository.add(entity)
|
||||
|
||||
# Index the file for search
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
created_at=file_metadata.created_at.timestamp(),
|
||||
modified_at=file_metadata.modified_at.timestamp(),
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions without wrapping
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error creating resource {data.file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{entity_id}", response_model=ResourceResponse)
|
||||
async def update_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: UpdateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> ResourceResponse:
|
||||
"""Update an existing resource by entity 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
|
||||
data: Update resource request with content and optional new file_path
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for updating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with updated file information
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 for invalid paths
|
||||
"""
|
||||
try:
|
||||
# Get existing entity
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
# Determine target file path
|
||||
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)
|
||||
if not validate_project_path(target_file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {target_file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {target_file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Write content to target file
|
||||
checksum = await file_service.write_file(target_file_path, data.content)
|
||||
|
||||
# Get file info
|
||||
file_metadata = await file_service.get_file_metadata(target_file_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(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
|
||||
updated_entity = await entity_repository.update(
|
||||
entity_id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
"content_type": content_type,
|
||||
"file_path": target_file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": file_metadata.modified_at,
|
||||
},
|
||||
)
|
||||
|
||||
# Index the updated file for search
|
||||
await search_service.index_entity(updated_entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity_id,
|
||||
file_path=target_file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
created_at=file_metadata.created_at.timestamp(),
|
||||
modified_at=file_metadata.modified_at.timestamp(),
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions without wrapping
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error updating resource {entity_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""V2 router for search operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.post("/search/", response_model=SearchResponse)
|
||||
async def search(
|
||||
project_id: ProjectIdPathDep,
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
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.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
query: Search query parameters (text, filters, etc.)
|
||||
search_service: Search service scoped to project
|
||||
entity_service: Entity service scoped to project
|
||||
page: Page number for pagination
|
||||
page_size: Number of results per page
|
||||
|
||||
Returns:
|
||||
SearchResponse with paginated search results
|
||||
"""
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search/reindex")
|
||||
async def reindex(
|
||||
project_id: ProjectIdPathDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
search_service: SearchServiceV2Dep,
|
||||
):
|
||||
"""Recreate and populate the search index for a project.
|
||||
|
||||
This is a background operation that rebuilds the search index
|
||||
from scratch. Useful after bulk updates or if the index becomes
|
||||
corrupted.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
background_tasks: FastAPI background tasks handler
|
||||
search_service: Search service scoped to project
|
||||
|
||||
Returns:
|
||||
Status message indicating reindex has been initiated
|
||||
"""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
@@ -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
|
||||
# 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:
|
||||
@@ -31,8 +44,25 @@ def app_callback(
|
||||
) -> None:
|
||||
"""Basic Memory - Local-first personal knowledge management."""
|
||||
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
# Initialize logging for CLI (file only, no stdout)
|
||||
init_cli_logging()
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -9,17 +9,24 @@ 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 loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
console = Console()
|
||||
|
||||
# Minimum rclone version for --create-empty-src-dirs support
|
||||
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
|
||||
|
||||
|
||||
class RcloneError(Exception):
|
||||
"""Exception raised for rclone command errors."""
|
||||
@@ -27,6 +34,57 @@ class RcloneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def check_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():
|
||||
raise RcloneError(
|
||||
"rclone is not installed.\n\n"
|
||||
"Install rclone by running: bm cloud setup\n"
|
||||
"Or install manually from: https://rclone.org/downloads/\n\n"
|
||||
"Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_rclone_version() -> 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 = subprocess.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() -> bool:
|
||||
"""Check if installed rclone supports --create-empty-src-dirs flag.
|
||||
|
||||
Returns:
|
||||
True if rclone version >= 1.64.0, False otherwise.
|
||||
"""
|
||||
version = get_rclone_version()
|
||||
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.
|
||||
@@ -124,8 +182,10 @@ def project_sync(
|
||||
True if sync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -180,8 +240,10 @@ def project_bisync(
|
||||
True if bisync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path or needs --resync
|
||||
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -198,7 +260,6 @@ def project_bisync(
|
||||
"bisync",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
"--create-empty-src-dirs",
|
||||
"--resilient",
|
||||
"--conflict-resolve=newer",
|
||||
"--max-delete=25",
|
||||
@@ -209,6 +270,10 @@ def project_bisync(
|
||||
str(state_path),
|
||||
]
|
||||
|
||||
# Add --create-empty-src-dirs if rclone version supports it (v1.64+)
|
||||
if supports_create_empty_src_dirs():
|
||||
cmd.append("--create-empty-src-dirs")
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
else:
|
||||
@@ -249,8 +314,10 @@ def project_check(
|
||||
True if files match, False if differences found
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -291,7 +358,10 @@ def project_ls(
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If rclone command fails
|
||||
RcloneError: If rclone is not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
if path:
|
||||
remote_path = f"{remote_path}/{path}"
|
||||
|
||||
@@ -151,11 +151,25 @@ def install_rclone_windows() -> None:
|
||||
except RcloneInstallError:
|
||||
console.print("[yellow]scoop installation failed[/yellow]")
|
||||
|
||||
# No package manager available
|
||||
raise RcloneInstallError(
|
||||
"Could not install rclone automatically. Please install a package manager "
|
||||
"(winget, chocolatey, or scoop) or install rclone manually from https://rclone.org/downloads/"
|
||||
# No package manager available - provide detailed instructions
|
||||
error_msg = (
|
||||
"Could not install rclone automatically.\n\n"
|
||||
"Windows requires a package manager to install rclone. Options:\n\n"
|
||||
"1. Install winget (recommended, built into Windows 11):\n"
|
||||
" - Windows 11: Already installed\n"
|
||||
" - Windows 10: Install 'App Installer' from Microsoft Store\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"2. Install chocolatey:\n"
|
||||
" - Visit: https://chocolatey.org/install\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"3. Install scoop:\n"
|
||||
" - Visit: https://scoop.sh\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"4. Manual installation:\n"
|
||||
" - Download from: https://rclone.org/downloads/\n"
|
||||
" - Extract and add to PATH\n"
|
||||
)
|
||||
raise RcloneInstallError(error_msg)
|
||||
|
||||
|
||||
def install_rclone(platform_override: Optional[str] = None) -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
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 loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
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 loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
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 loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
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 loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@import_app.command()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""MCP server command with streamable HTTP transport."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager
|
||||
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,7 +40,11 @@ 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()
|
||||
|
||||
# Validate and set project constraint if specified
|
||||
if project:
|
||||
@@ -57,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['project_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['project_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()
|
||||
|
||||
+181
-81
@@ -6,12 +6,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional, List, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.utils import setup_logging, generate_permalink
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ WATCH_STATUS_JSON = "watch-status.json"
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class DatabaseBackend(str, Enum):
|
||||
"""Supported database backends."""
|
||||
|
||||
SQLITE = "sqlite"
|
||||
POSTGRES = "postgres"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
@@ -63,8 +70,10 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
|
||||
},
|
||||
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
}
|
||||
if os.getenv("BASIC_MEMORY_HOME")
|
||||
else {},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
)
|
||||
default_project: str = Field(
|
||||
@@ -79,13 +88,43 @@ class BasicMemoryConfig(BaseSettings):
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Database configuration
|
||||
database_backend: DatabaseBackend = Field(
|
||||
default=DatabaseBackend.SQLITE,
|
||||
description="Database backend to use (sqlite or postgres)",
|
||||
)
|
||||
|
||||
database_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
|
||||
)
|
||||
|
||||
# Database connection pool configuration (Postgres only)
|
||||
db_pool_size: int = Field(
|
||||
default=20,
|
||||
description="Number of connections to keep in the pool (Postgres only)",
|
||||
gt=0,
|
||||
)
|
||||
db_pool_overflow: int = Field(
|
||||
default=40,
|
||||
description="Max additional connections beyond pool_size under load (Postgres only)",
|
||||
gt=0,
|
||||
)
|
||||
db_pool_recycle: int = Field(
|
||||
default=180,
|
||||
description="Recycle connections after N seconds to prevent stale connections. Default 180s works well with Neon's ~5 minute scale-to-zero (Postgres only)",
|
||||
gt=0,
|
||||
)
|
||||
|
||||
# Watch service configuration
|
||||
sync_delay: int = Field(
|
||||
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
||||
)
|
||||
|
||||
watch_project_reload_interval: int = Field(
|
||||
default=30, description="Seconds between reloading project list in watch service", gt=0
|
||||
default=300,
|
||||
description="Seconds between reloading project list in watch service. Higher values reduce CPU usage by minimizing watcher restarts. Default 300s (5 min) balances efficiency with responsiveness to new projects.",
|
||||
gt=0,
|
||||
)
|
||||
|
||||
# update permalinks on move
|
||||
@@ -126,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,
|
||||
@@ -160,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.
|
||||
@@ -176,6 +265,36 @@ class BasicMemoryConfig(BaseSettings):
|
||||
# Fall back to config file value
|
||||
return self.cloud_mode
|
||||
|
||||
@classmethod
|
||||
def for_cloud_tenant(
|
||||
cls,
|
||||
database_url: str,
|
||||
projects: Optional[Dict[str, str]] = None,
|
||||
) -> "BasicMemoryConfig":
|
||||
"""Create config for cloud tenant - no config.json, database is source of truth.
|
||||
|
||||
This factory method creates a BasicMemoryConfig suitable for cloud deployments
|
||||
where:
|
||||
- Database is Postgres (Neon), not SQLite
|
||||
- Projects are discovered from the database, not config file
|
||||
- Path validation is skipped (no local filesystem in cloud)
|
||||
- Initialization sync is skipped (stateless deployment)
|
||||
|
||||
Args:
|
||||
database_url: Postgres connection URL for tenant database
|
||||
projects: Optional project mapping (usually empty, discovered from DB)
|
||||
|
||||
Returns:
|
||||
BasicMemoryConfig configured for cloud mode
|
||||
"""
|
||||
return cls(
|
||||
database_backend=DatabaseBackend.POSTGRES,
|
||||
database_url=database_url,
|
||||
projects=projects or {},
|
||||
cloud_mode=True,
|
||||
skip_initialization_sync=True,
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
@@ -192,15 +311,20 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
# Ensure main project exists
|
||||
if "main" not in self.projects: # pragma: no cover
|
||||
self.projects["main"] = (
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
).as_posix()
|
||||
# Skip project initialization in cloud mode - projects are discovered from DB
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return
|
||||
|
||||
# Ensure default project is valid
|
||||
# Ensure at least one project exists; if none exist then create main
|
||||
if not self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
)
|
||||
|
||||
# Ensure default project is valid (i.e. points to an existing project)
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
self.default_project = "main"
|
||||
# Set default to first available project
|
||||
self.default_project = next(iter(self.projects.keys()))
|
||||
|
||||
@property
|
||||
def app_database_path(self) -> Path:
|
||||
@@ -233,19 +357,26 @@ class BasicMemoryConfig(BaseSettings):
|
||||
"""Get all configured projects as ProjectConfig objects."""
|
||||
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
|
||||
|
||||
@field_validator("projects")
|
||||
@classmethod
|
||||
def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover
|
||||
"""Ensure project path exists."""
|
||||
for name, path_value in v.items():
|
||||
@model_validator(mode="after")
|
||||
def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
|
||||
"""Ensure project paths exist.
|
||||
|
||||
Skips path creation when using Postgres backend (cloud mode) since
|
||||
cloud tenants don't use local filesystem paths.
|
||||
"""
|
||||
# Skip path creation for cloud mode - no local filesystem
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return self
|
||||
|
||||
for name, path_value in self.projects.items():
|
||||
path = Path(path_value)
|
||||
if not Path(path).exists():
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create project path: {e}")
|
||||
raise e
|
||||
return v
|
||||
return self
|
||||
|
||||
@property
|
||||
def data_dir_path(self):
|
||||
@@ -358,7 +489,7 @@ class ConfigManager:
|
||||
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = project_path.as_posix()
|
||||
config.projects[name] = str(project_path)
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
@@ -448,69 +579,38 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
|
||||
|
||||
# setup logging to a single log file in user home directory
|
||||
user_home = Path.home()
|
||||
log_dir = user_home / DATA_DIR_NAME
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Logging initialization functions for different entry points
|
||||
|
||||
|
||||
# Process info for logging
|
||||
def get_process_name(): # pragma: no cover
|
||||
def init_cli_logging() -> None: # pragma: no cover
|
||||
"""Initialize logging for CLI commands - file only.
|
||||
|
||||
CLI commands should not log to stdout to avoid interfering with
|
||||
command output and shell integration.
|
||||
"""
|
||||
get the type of process for logging
|
||||
"""
|
||||
import sys
|
||||
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
|
||||
setup_logging(log_level=log_level, log_to_file=True)
|
||||
|
||||
if "sync" in sys.argv:
|
||||
return "sync"
|
||||
elif "mcp" in sys.argv:
|
||||
return "mcp"
|
||||
elif "cli" in sys.argv:
|
||||
return "cli"
|
||||
|
||||
def init_mcp_logging() -> None: # pragma: no cover
|
||||
"""Initialize logging for MCP server - file only.
|
||||
|
||||
MCP server must not log to stdout as it would corrupt the
|
||||
JSON-RPC protocol communication.
|
||||
"""
|
||||
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
|
||||
setup_logging(log_level=log_level, log_to_file=True)
|
||||
|
||||
|
||||
def init_api_logging() -> None: # pragma: no cover
|
||||
"""Initialize logging for API server.
|
||||
|
||||
Cloud mode (BASIC_MEMORY_CLOUD_MODE=1): stdout with structured context
|
||||
Local mode: file only
|
||||
"""
|
||||
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
|
||||
cloud_mode = os.getenv("BASIC_MEMORY_CLOUD_MODE", "").lower() in ("1", "true")
|
||||
if cloud_mode:
|
||||
setup_logging(log_level=log_level, log_to_stdout=True, structured_context=True)
|
||||
else:
|
||||
return "api"
|
||||
|
||||
|
||||
process_name = get_process_name()
|
||||
|
||||
# Global flag to track if logging has been set up
|
||||
_LOGGING_SETUP = False
|
||||
|
||||
|
||||
# Logging
|
||||
|
||||
|
||||
def setup_basic_memory_logging(): # pragma: no cover
|
||||
"""Set up logging for basic-memory, ensuring it only happens once."""
|
||||
global _LOGGING_SETUP
|
||||
if _LOGGING_SETUP:
|
||||
# We can't log before logging is set up
|
||||
# print("Skipping duplicate logging setup")
|
||||
return
|
||||
|
||||
# Check for console logging environment variable - accept more truthy values
|
||||
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
|
||||
console_logging = console_logging_env in ("true", "1", "yes", "on")
|
||||
|
||||
# Check for log level environment variable first, fall back to config
|
||||
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
|
||||
if not log_level:
|
||||
config_manager = ConfigManager()
|
||||
log_level = config_manager.config.log_level
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = get_project_config()
|
||||
setup_logging(
|
||||
env=config_manager.config.env,
|
||||
home_dir=user_home, # Use user home for logs
|
||||
log_level=log_level,
|
||||
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
||||
console=console_logging,
|
||||
)
|
||||
|
||||
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
||||
_LOGGING_SETUP = True
|
||||
|
||||
|
||||
# Set up logging
|
||||
setup_basic_memory_logging()
|
||||
setup_logging(log_level=log_level, log_to_file=True)
|
||||
|
||||
+155
-73
@@ -1,11 +1,12 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -20,12 +21,27 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
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
|
||||
_migrations_completed: bool = False
|
||||
|
||||
|
||||
class DatabaseType(Enum):
|
||||
@@ -33,10 +49,41 @@ class DatabaseType(Enum):
|
||||
|
||||
MEMORY = auto()
|
||||
FILESYSTEM = auto()
|
||||
POSTGRES = auto()
|
||||
|
||||
@classmethod
|
||||
def get_db_url(cls, db_path: Path, db_type: "DatabaseType") -> str:
|
||||
"""Get SQLAlchemy URL for database path."""
|
||||
def get_db_url(
|
||||
cls, db_path: Path, db_type: "DatabaseType", config: Optional[BasicMemoryConfig] = None
|
||||
) -> str:
|
||||
"""Get SQLAlchemy URL for database path.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file (ignored for Postgres)
|
||||
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
|
||||
config: Optional config to check for database backend and URL
|
||||
|
||||
Returns:
|
||||
SQLAlchemy connection URL
|
||||
"""
|
||||
# Load config if not provided
|
||||
if config is None:
|
||||
config = ConfigManager().config
|
||||
|
||||
# Handle explicit Postgres type
|
||||
if db_type == cls.POSTGRES:
|
||||
if not config.database_url:
|
||||
raise ValueError("DATABASE_URL must be set when using Postgres backend")
|
||||
logger.info(f"Using Postgres database: {config.database_url}")
|
||||
return config.database_url
|
||||
|
||||
# Check if Postgres backend is configured (for backward compatibility)
|
||||
if config.database_backend == DatabaseBackend.POSTGRES:
|
||||
if not config.database_url:
|
||||
raise ValueError("DATABASE_URL must be set when using Postgres backend")
|
||||
logger.info(f"Using Postgres database: {config.database_url}")
|
||||
return config.database_url
|
||||
|
||||
# SQLite databases
|
||||
if db_type == cls.MEMORY:
|
||||
logger.info("Using in-memory SQLite database")
|
||||
return "sqlite+aiosqlite://"
|
||||
@@ -64,7 +111,14 @@ async def scoped_session(
|
||||
factory = get_scoped_session_factory(session_maker)
|
||||
session = factory()
|
||||
try:
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
# Only enable foreign keys for SQLite (Postgres has them enabled by default)
|
||||
# Detect database type from session's bind (engine) dialect
|
||||
engine = session.get_bind()
|
||||
dialect_name = engine.dialect.name
|
||||
|
||||
if dialect_name == "sqlite":
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
@@ -103,13 +157,16 @@ def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _create_engine_and_session(
|
||||
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Internal helper to create engine and session maker."""
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
|
||||
"""Create SQLite async engine with appropriate configuration.
|
||||
|
||||
Args:
|
||||
db_url: SQLite connection URL
|
||||
db_type: Database type (MEMORY or FILESYSTEM)
|
||||
|
||||
Returns:
|
||||
Configured async engine for SQLite
|
||||
"""
|
||||
# Configure connection args with Windows-specific settings
|
||||
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
||||
|
||||
@@ -146,6 +203,67 @@ def _create_engine_and_session(
|
||||
"""Enable WAL mode on each connection."""
|
||||
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngine:
|
||||
"""Create Postgres async engine with appropriate configuration.
|
||||
|
||||
Args:
|
||||
db_url: Postgres connection URL (postgresql+asyncpg://...)
|
||||
config: BasicMemoryConfig with pool settings
|
||||
|
||||
Returns:
|
||||
Configured async engine for Postgres
|
||||
"""
|
||||
# Use NullPool connection issues.
|
||||
# Assume connection pooler like PgBouncer handles connection pooling.
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
poolclass=NullPool, # No pooling - fresh connection per request
|
||||
connect_args={
|
||||
# Disable statement cache to avoid issues with prepared statements on reconnect
|
||||
"statement_cache_size": 0,
|
||||
# Allow 30s for commands (Neon cold start can take 2-5s, sometimes longer)
|
||||
"command_timeout": 30,
|
||||
# Allow 30s for initial connection (Neon wake-up time)
|
||||
"timeout": 30,
|
||||
"server_settings": {
|
||||
"application_name": "basic-memory",
|
||||
# Statement timeout for queries (30s to allow for cold start)
|
||||
"statement_timeout": "30s",
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.debug("Created Postgres engine with NullPool (no connection pooling)")
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _create_engine_and_session(
|
||||
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Internal helper to create engine and session maker.
|
||||
|
||||
Args:
|
||||
db_path: Path to database file (used for SQLite, ignored for Postgres)
|
||||
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, session_maker)
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type, config)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
|
||||
# Delegate to backend-specific engine creation
|
||||
# Check explicit POSTGRES type first, then config setting
|
||||
if db_type == DatabaseType.POSTGRES or config.database_backend == DatabaseBackend.POSTGRES:
|
||||
engine = _create_postgres_engine(db_url, config)
|
||||
else:
|
||||
engine = _create_sqlite_engine(db_url, db_type)
|
||||
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
return engine, session_maker
|
||||
|
||||
@@ -181,13 +299,12 @@ async def get_or_create_db(
|
||||
|
||||
async def shutdown_db() -> None: # pragma: no cover
|
||||
"""Clean up database connections."""
|
||||
global _engine, _session_maker, _migrations_completed
|
||||
global _engine, _session_maker
|
||||
|
||||
if _engine:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_maker = None
|
||||
_migrations_completed = False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -201,50 +318,12 @@ async def engine_session_factory(
|
||||
for each test. For production use, use get_or_create_db() instead.
|
||||
"""
|
||||
|
||||
global _engine, _session_maker, _migrations_completed
|
||||
global _engine, _session_maker
|
||||
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
|
||||
# Configure connection args with Windows-specific settings
|
||||
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
||||
|
||||
# Add Windows-specific parameters to improve reliability
|
||||
if os.name == "nt": # Windows
|
||||
connect_args.update(
|
||||
{
|
||||
"timeout": 30.0, # Increase timeout to 30 seconds for Windows
|
||||
"isolation_level": None, # Use autocommit mode
|
||||
}
|
||||
)
|
||||
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
|
||||
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
|
||||
# between connections
|
||||
if db_type == DatabaseType.FILESYSTEM:
|
||||
_engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=NullPool, # Disable connection pooling on Windows
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
# In-memory databases need connection pooling to maintain state
|
||||
_engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
else:
|
||||
_engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
|
||||
# Enable WAL mode for better concurrency and reliability
|
||||
# Note: WAL mode is not supported for in-memory databases
|
||||
enable_wal = db_type != DatabaseType.MEMORY
|
||||
|
||||
@event.listens_for(_engine.sync_engine, "connect")
|
||||
def enable_wal_mode(dbapi_conn, connection_record):
|
||||
"""Enable WAL mode on each connection."""
|
||||
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
|
||||
|
||||
try:
|
||||
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
# Verify that engine and session maker are initialized
|
||||
if _engine is None: # pragma: no cover
|
||||
logger.error("Database engine is None in engine_session_factory")
|
||||
@@ -260,20 +339,16 @@ async def engine_session_factory(
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_maker = None
|
||||
_migrations_completed = False
|
||||
|
||||
|
||||
async def run_migrations(
|
||||
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM, force: bool = False
|
||||
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
|
||||
): # pragma: no cover
|
||||
"""Run any pending alembic migrations."""
|
||||
global _migrations_completed
|
||||
|
||||
# Skip if migrations already completed unless forced
|
||||
if _migrations_completed and not force:
|
||||
logger.debug("Migrations already completed in this session, skipping")
|
||||
return
|
||||
"""Run any pending alembic migrations.
|
||||
|
||||
Note: Alembic tracks which migrations have been applied via the alembic_version table,
|
||||
so it's safe to call this multiple times - it will only run pending migrations.
|
||||
"""
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
# Get the absolute path to the alembic directory relative to this file
|
||||
@@ -288,9 +363,11 @@ async def run_migrations(
|
||||
)
|
||||
config.set_main_option("timezone", "UTC")
|
||||
config.set_main_option("revision_environment", "false")
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
|
||||
)
|
||||
|
||||
# Get the correct database URL based on backend configuration
|
||||
# No URL conversion needed - env.py now handles both async and sync engines
|
||||
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Migrations completed successfully")
|
||||
@@ -301,12 +378,17 @@ async def run_migrations(
|
||||
else:
|
||||
session_maker = _session_maker
|
||||
|
||||
# initialize the search Index schema
|
||||
# the project_id is not used for init_search_index, so we pass a dummy value
|
||||
await SearchRepository(session_maker, 1).init_search_index()
|
||||
|
||||
# Mark migrations as completed
|
||||
_migrations_completed = True
|
||||
# Initialize the search index schema
|
||||
# For SQLite: Create FTS5 virtual table
|
||||
# For Postgres: No-op (tsvector column added by migrations)
|
||||
# The project_id is not used for init_search_index, so we pass a dummy value
|
||||
if (
|
||||
database_type == DatabaseType.POSTGRES
|
||||
or app_config.database_backend == DatabaseBackend.POSTGRES
|
||||
):
|
||||
await PostgresSearchRepository(session_maker, 1).init_search_index()
|
||||
else:
|
||||
await SQLiteSearchRepository(session_maker, 1).init_search_index()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
|
||||
+303
-9
@@ -25,7 +25,7 @@ from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, create_search_repository
|
||||
from basic_memory.services import EntityService, ProjectService
|
||||
from basic_memory.services.context_service import ContextService
|
||||
from basic_memory.services.directory_service import DirectoryService
|
||||
@@ -76,6 +76,34 @@ async def get_project_config(
|
||||
|
||||
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
|
||||
|
||||
|
||||
async def get_project_config_v2(
|
||||
project_id: "ProjectIdPathDep", project_repository: "ProjectRepositoryDep"
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the project config for v2 API (uses integer project_id from path).
|
||||
|
||||
Args:
|
||||
project_id: The validated numeric project ID from the URL path
|
||||
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 ProjectIdPathDep already validates existence)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)] # pragma: no cover
|
||||
|
||||
## sqlalchemy
|
||||
|
||||
|
||||
@@ -130,6 +158,38 @@ ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_reposito
|
||||
ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
|
||||
|
||||
|
||||
async def validate_project_id(
|
||||
project_id: int,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> int:
|
||||
"""Validate that a numeric project ID exists in the database.
|
||||
|
||||
This is used for v2 API endpoints that take project IDs as integers in the path.
|
||||
The project_id parameter will be automatically extracted from the URL path by FastAPI.
|
||||
|
||||
Args:
|
||||
project_id: The numeric project ID from the URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The validated project ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If project with that ID is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if not project_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with ID {project_id} not found.",
|
||||
)
|
||||
return project_id
|
||||
|
||||
|
||||
# V2 API: Validated integer project ID from path
|
||||
ProjectIdPathDep = Annotated[int, Depends(validate_project_id)]
|
||||
|
||||
|
||||
async def get_project_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project: ProjectPathDep,
|
||||
@@ -188,6 +248,17 @@ async def get_entity_repository(
|
||||
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
|
||||
|
||||
|
||||
async def get_entity_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for v2 API (uses integer project_id from path)."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
|
||||
|
||||
|
||||
async def get_observation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -199,6 +270,19 @@ async def get_observation_repository(
|
||||
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
|
||||
|
||||
|
||||
async def get_observation_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for v2 API."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryV2Dep = Annotated[
|
||||
ObservationRepository, Depends(get_observation_repository_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_relation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -210,17 +294,43 @@ async def get_relation_repository(
|
||||
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
|
||||
|
||||
|
||||
async def get_relation_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for v2 API."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
|
||||
|
||||
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for the current project."""
|
||||
return SearchRepository(session_maker, project_id=project_id)
|
||||
"""Create a backend-specific SearchRepository instance for the current project.
|
||||
|
||||
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
|
||||
based on database backend configuration.
|
||||
"""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
||||
|
||||
|
||||
async def get_search_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API."""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
|
||||
|
||||
|
||||
# ProjectInfoRepository is deprecated and will be removed in a future version.
|
||||
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
|
||||
|
||||
@@ -234,27 +344,61 @@ async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
|
||||
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
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, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
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, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
|
||||
)
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
logger.debug(f"Created FileService for project: {file_service} ")
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2(
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
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
|
||||
|
||||
|
||||
FileServiceV2Dep = Annotated[FileService, Depends(get_file_service_v2)]
|
||||
|
||||
|
||||
async def get_entity_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
@@ -262,6 +406,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."""
|
||||
@@ -272,6 +417,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,
|
||||
)
|
||||
|
||||
@@ -279,6 +425,32 @@ async def get_entity_service(
|
||||
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
|
||||
|
||||
|
||||
async def get_entity_service_v2(
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
link_resolver: "LinkResolverV2Dep",
|
||||
search_service: "SearchServiceV2Dep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceV2Dep = Annotated[EntityService, Depends(get_entity_service_v2)]
|
||||
|
||||
|
||||
async def get_search_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -291,6 +463,18 @@ async def get_search_service(
|
||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
||||
|
||||
|
||||
async def get_search_service_v2(
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService for v2 API."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceV2Dep = Annotated[SearchService, Depends(get_search_service_v2)]
|
||||
|
||||
|
||||
async def get_link_resolver(
|
||||
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
|
||||
) -> LinkResolver:
|
||||
@@ -300,6 +484,15 @@ async def get_link_resolver(
|
||||
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
||||
|
||||
|
||||
async def get_link_resolver_v2(
|
||||
entity_repository: EntityRepositoryV2Dep, search_service: SearchServiceV2Dep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverV2Dep = Annotated[LinkResolver, Depends(get_link_resolver_v2)]
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -315,6 +508,22 @@ async def get_context_service(
|
||||
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
||||
|
||||
|
||||
async def get_context_service_v2(
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
) -> ContextService:
|
||||
"""Create ContextService for v2 API."""
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceV2Dep = Annotated[ContextService, Depends(get_context_service_v2)]
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceDep,
|
||||
@@ -344,6 +553,32 @@ async def get_sync_service(
|
||||
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||
|
||||
|
||||
async def get_sync_service_v2(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""Create SyncService for v2 API."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceV2Dep = Annotated[SyncService, Depends(get_sync_service_v2)]
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectService:
|
||||
@@ -366,6 +601,18 @@ async def get_directory_service(
|
||||
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
|
||||
|
||||
|
||||
async def get_directory_service_v2(
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService for v2 API (uses integer project_id from path)."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceV2Dep = Annotated[DirectoryService, Depends(get_directory_service_v2)]
|
||||
|
||||
|
||||
# Import
|
||||
|
||||
|
||||
@@ -409,3 +656,50 @@ async def get_memory_json_importer(
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
|
||||
|
||||
# V2 Import dependencies
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
|
||||
@@ -1,9 +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
|
||||
@@ -12,6 +16,23 @@ from loguru import logger
|
||||
|
||||
from basic_memory.utils import FilePath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
"""File metadata for cloud-compatible file operations.
|
||||
|
||||
This dataclass provides a cloud-agnostic way to represent file metadata,
|
||||
enabling S3FileService to return metadata from head_object responses
|
||||
instead of mock stat_result with zeros.
|
||||
"""
|
||||
|
||||
size: int
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
|
||||
|
||||
class FileError(Exception):
|
||||
"""Base exception for file operations."""
|
||||
@@ -53,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.
|
||||
@@ -84,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:
|
||||
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:
|
||||
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:
|
||||
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.
|
||||
@@ -97,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
|
||||
|
||||
@@ -118,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")
|
||||
|
||||
@@ -159,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("---"):
|
||||
|
||||
@@ -40,10 +40,13 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Get name, providing default for unnamed conversations
|
||||
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
base_path=folder_path,
|
||||
name=chat["name"],
|
||||
name=chat_name,
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ from basic_memory.markdown.schemas import (
|
||||
)
|
||||
from basic_memory.utils import parse_tags
|
||||
|
||||
|
||||
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
|
||||
|
||||
|
||||
@@ -189,35 +190,69 @@ class EntityParser:
|
||||
return self.base_path / path
|
||||
|
||||
async def parse_file_content(self, absolute_path, file_content):
|
||||
# Parse frontmatter with proper error handling for malformed YAML (issue #185)
|
||||
try:
|
||||
post = frontmatter.loads(file_content)
|
||||
except yaml.YAMLError as e:
|
||||
# Log the YAML parsing error with file context
|
||||
logger.warning(
|
||||
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
|
||||
f"Treating file as plain markdown without frontmatter."
|
||||
)
|
||||
# Create a post with no frontmatter - treat entire content as markdown
|
||||
post = frontmatter.Post(file_content, metadata={})
|
||||
"""Parse markdown content from file stats.
|
||||
|
||||
# Extract file stat info
|
||||
Delegates to parse_markdown_content() for actual parsing logic.
|
||||
Exists for backwards compatibility with code that passes file paths.
|
||||
"""
|
||||
# Extract file stat info for timestamps
|
||||
file_stats = absolute_path.stat()
|
||||
|
||||
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
|
||||
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
|
||||
# This normalization converts them back to ISO format strings to ensure compatibility
|
||||
# with code that expects string values
|
||||
# Delegate to parse_markdown_content with timestamps from file stats
|
||||
return await self.parse_markdown_content(
|
||||
file_path=absolute_path,
|
||||
content=file_content,
|
||||
mtime=file_stats.st_mtime,
|
||||
ctime=file_stats.st_ctime,
|
||||
)
|
||||
|
||||
async def parse_markdown_content(
|
||||
self,
|
||||
file_path: Path,
|
||||
content: str,
|
||||
mtime: Optional[float] = None,
|
||||
ctime: Optional[float] = None,
|
||||
) -> EntityMarkdown:
|
||||
"""Parse markdown content without requiring file to exist on disk.
|
||||
|
||||
Useful for parsing content from S3 or other remote sources where the file
|
||||
is not available locally.
|
||||
|
||||
Args:
|
||||
file_path: Path for metadata (doesn't need to exist on disk)
|
||||
content: Markdown content as string
|
||||
mtime: Optional modification time (Unix timestamp)
|
||||
ctime: Optional creation time (Unix timestamp)
|
||||
|
||||
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)
|
||||
except yaml.YAMLError as e:
|
||||
logger.warning(
|
||||
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
|
||||
f"Treating file as plain markdown without frontmatter."
|
||||
)
|
||||
post = frontmatter.Post(content, metadata={})
|
||||
|
||||
# Normalize frontmatter values
|
||||
metadata = normalize_frontmatter_metadata(post.metadata)
|
||||
|
||||
# Ensure required fields have defaults (issue #184, #387)
|
||||
# Handle title - use default if missing, None/null, empty, or string "None"
|
||||
# Ensure required fields have defaults
|
||||
title = metadata.get("title")
|
||||
if not title or title == "None":
|
||||
metadata["title"] = absolute_path.stem
|
||||
metadata["title"] = file_path.stem
|
||||
else:
|
||||
metadata["title"] = title
|
||||
# Handle type - use default if missing OR explicitly set to None/null
|
||||
|
||||
entity_type = metadata.get("type")
|
||||
metadata["type"] = entity_type if entity_type is not None else "note"
|
||||
|
||||
@@ -225,16 +260,20 @@ class EntityParser:
|
||||
if tags:
|
||||
metadata["tags"] = tags
|
||||
|
||||
# frontmatter - use metadata with defaults applied
|
||||
entity_frontmatter = EntityFrontmatter(
|
||||
metadata=metadata,
|
||||
)
|
||||
# Parse content for observations and relations
|
||||
entity_frontmatter = EntityFrontmatter(metadata=metadata)
|
||||
entity_content = parse(post.content)
|
||||
|
||||
# Use provided timestamps or current time as fallback
|
||||
now = datetime.now().astimezone()
|
||||
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
|
||||
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
|
||||
|
||||
return EntityMarkdown(
|
||||
frontmatter=entity_frontmatter,
|
||||
content=post.content,
|
||||
observations=entity_content.observations,
|
||||
relations=entity_content.relations,
|
||||
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
||||
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
created=created,
|
||||
modified=modified,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from collections import OrderedDict
|
||||
|
||||
from frontmatter import Post
|
||||
from loguru import logger
|
||||
|
||||
|
||||
from basic_memory import file_utils
|
||||
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:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class DirtyFileError(Exception):
|
||||
"""Raised when attempting to write to a file that has been modified."""
|
||||
@@ -35,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.
|
||||
@@ -122,7 +131,17 @@ 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(
|
||||
path, self.app_config, is_markdown=True
|
||||
)
|
||||
if formatted_content is not None:
|
||||
content_for_checksum = formatted_content
|
||||
|
||||
return await file_utils.compute_checksum(content_for_checksum)
|
||||
|
||||
def format_observations(self, observations: list[Observation]) -> str:
|
||||
"""Format observations section in standard way.
|
||||
|
||||
@@ -30,7 +30,9 @@ def is_observation(token: Token) -> bool:
|
||||
|
||||
# Check for proper observation format: [category] content
|
||||
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
||||
has_tags = "#" in content
|
||||
# Check for standalone hashtags (words starting with #)
|
||||
# This excludes # in HTML attributes like color="#4285F4"
|
||||
has_tags = any(part.startswith("#") for part in content.split())
|
||||
return bool(match) or has_tags
|
||||
|
||||
|
||||
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
|
||||
|
||||
target = content[start + 2 : end].strip()
|
||||
if target:
|
||||
relations.append({"type": "links to", "target": target, "context": None})
|
||||
relations.append({"type": "links_to", "target": target, "context": None})
|
||||
|
||||
start = end + 2
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
from frontmatter import Post
|
||||
|
||||
from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
|
||||
@@ -12,7 +13,10 @@ from basic_memory.models import Observation as ObservationModel
|
||||
|
||||
|
||||
def entity_model_from_markdown(
|
||||
file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None
|
||||
file_path: Path,
|
||||
markdown: EntityMarkdown,
|
||||
entity: Optional[Entity] = None,
|
||||
project_id: Optional[int] = None,
|
||||
) -> Entity:
|
||||
"""
|
||||
Convert markdown entity to model. Does not include relations.
|
||||
@@ -21,6 +25,7 @@ def entity_model_from_markdown(
|
||||
file_path: Path to the markdown file
|
||||
markdown: Parsed markdown entity
|
||||
entity: Optional existing entity to update
|
||||
project_id: Project ID for new observations (uses entity.project_id if not provided)
|
||||
|
||||
Returns:
|
||||
Entity model populated from markdown
|
||||
@@ -50,9 +55,13 @@ def entity_model_from_markdown(
|
||||
metadata = markdown.frontmatter.metadata or {}
|
||||
model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
|
||||
|
||||
# Get project_id from entity if not provided
|
||||
obs_project_id = project_id or (model.project_id if hasattr(model, "project_id") else None)
|
||||
|
||||
# Convert observations
|
||||
model.observations = [
|
||||
ObservationModel(
|
||||
project_id=obs_project_id,
|
||||
content=obs.content,
|
||||
category=obs.category,
|
||||
context=obs.context,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
logger.info("Cloud mode enabled - skipping local file sync")
|
||||
else:
|
||||
logger.info("Sync changes disabled - skipping file sync")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down Basic Memory MCP server")
|
||||
if sync_task:
|
||||
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:
|
||||
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.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.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.id, file_path)
|
||||
# For update, send content in JSON body
|
||||
response = await call_put(
|
||||
client,
|
||||
f"/v2/projects/{active_project.id}/resource/{entity_id}",
|
||||
json={"content": canvas_json},
|
||||
)
|
||||
action = "Updated"
|
||||
except Exception as update_error:
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise
|
||||
|
||||
# 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]]:
|
||||
@@ -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,27 @@ 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.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(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
|
||||
return _format_delete_error_response(active_project.name, str(e), identifier)
|
||||
|
||||
try:
|
||||
# Call the DELETE endpoint
|
||||
response = await call_delete(
|
||||
client, f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
|
||||
)
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
if result.deleted:
|
||||
|
||||
@@ -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.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.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.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
|
||||
|
||||
|
||||
@@ -395,11 +396,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 +435,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.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.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 +470,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.id, identifier)
|
||||
# Fetch source entity information
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
|
||||
@@ -505,16 +510,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.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.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")
|
||||
@@ -179,11 +183,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 ID
|
||||
response = await call_delete(client, f"/v2/projects/{target_project.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.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.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.id, entity_path)
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.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.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.id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -9,6 +9,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 +99,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 +135,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
|
||||
@@ -247,11 +250,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.id}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
activity_data = GraphContext.model_validate(response.json())
|
||||
@@ -274,10 +276,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.id}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
activity = GraphContext.model_validate(activity_response.json())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -330,6 +331,7 @@ async def search_notes(
|
||||
# Explicit project specification
|
||||
results = await search_notes("project planning", project="my-project")
|
||||
"""
|
||||
track_mcp_tool("search_notes")
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
@@ -355,14 +357,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.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_id: int, identifier: str) -> int:
|
||||
"""Resolve a string identifier to an entity ID using the v2 API.
|
||||
|
||||
Args:
|
||||
client: HTTP client for API calls
|
||||
project_id: Project ID
|
||||
identifier: The identifier to resolve (permalink, title, or path)
|
||||
|
||||
Returns:
|
||||
The resolved entity ID
|
||||
|
||||
Raises:
|
||||
ToolError: If the identifier cannot be resolved
|
||||
"""
|
||||
try:
|
||||
response = await call_post(
|
||||
client, f"/v2/projects/{project_id}/knowledge/resolve", json={"identifier": identifier}
|
||||
)
|
||||
data = response.json()
|
||||
return data["entity_id"]
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise ToolError(f"Entity not found: '{identifier}'")
|
||||
raise ToolError(f"Error resolving identifier '{identifier}': {e}")
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}")
|
||||
|
||||
|
||||
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.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")
|
||||
entity_id = await resolve_entity_id(client, active_project.id, entity.permalink)
|
||||
url = f"/v2/projects/{active_project.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:
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise
|
||||
summary = [
|
||||
f"# {action} note",
|
||||
f"project: {active_project.name}",
|
||||
|
||||
@@ -129,7 +129,7 @@ class Entity(Base):
|
||||
return value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
||||
|
||||
|
||||
class Observation(Base):
|
||||
@@ -145,6 +145,7 @@ class Observation(Base):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
category: Mapped[str] = mapped_column(String, nullable=False, default="note")
|
||||
@@ -162,9 +163,14 @@ class Observation(Base):
|
||||
|
||||
We can construct these because observations are always defined in
|
||||
and owned by a single entity.
|
||||
|
||||
Content is truncated to 200 chars to stay under PostgreSQL's
|
||||
btree index limit of 2704 bytes.
|
||||
"""
|
||||
# Truncate content to avoid exceeding PostgreSQL's btree index limit
|
||||
content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
|
||||
return generate_permalink(
|
||||
f"{self.entity.permalink}/observations/{self.category}/{self.content}"
|
||||
f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
@@ -186,6 +192,7 @@ class Relation(Base):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
||||
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
||||
to_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
"""Search models and tables."""
|
||||
"""Search DDL statements for SQLite and Postgres.
|
||||
|
||||
The search_index table is created via raw DDL, not ORM models, because:
|
||||
- SQLite uses FTS5 virtual tables (cannot be represented as ORM)
|
||||
- Postgres uses composite primary keys and generated tsvector columns
|
||||
- Both backends use raw SQL for all search operations via SearchIndexRow dataclass
|
||||
"""
|
||||
|
||||
from sqlalchemy import DDL
|
||||
|
||||
# Define FTS5 virtual table creation
|
||||
|
||||
# Define Postgres search_index table with composite primary key and tsvector
|
||||
# This DDL matches the Alembic migration schema (314f1ea54dc4)
|
||||
# Used by tests to create the table without running full migrations
|
||||
# NOTE: Split into separate DDL statements because asyncpg doesn't support
|
||||
# multiple statements in a single execute call.
|
||||
CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL("""
|
||||
CREATE TABLE IF NOT EXISTS search_index (
|
||||
id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
content_stems TEXT,
|
||||
content_snippet TEXT,
|
||||
permalink VARCHAR,
|
||||
file_path VARCHAR,
|
||||
type VARCHAR,
|
||||
from_id INTEGER,
|
||||
to_id INTEGER,
|
||||
relation_type VARCHAR,
|
||||
entity_id INTEGER,
|
||||
category VARCHAR,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
||||
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
|
||||
) STORED,
|
||||
PRIMARY KEY (id, type, project_id),
|
||||
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
|
||||
CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
|
||||
""")
|
||||
|
||||
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)
|
||||
""")
|
||||
|
||||
# Define FTS5 virtual table creation for SQLite only
|
||||
# This DDL is executed separately for SQLite databases
|
||||
CREATE_SEARCH_INDEX = DDL("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
||||
-- Core entity fields
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Repository for managing entities in the knowledge graph."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Union
|
||||
from typing import List, Optional, Sequence, Union, Any
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
@@ -9,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
from sqlalchemy.engine import Row
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.knowledge import Entity, Observation, Relation
|
||||
@@ -31,6 +33,18 @@ class EntityRepository(Repository[Entity]):
|
||||
"""
|
||||
super().__init__(session_maker, Entity, project_id=project_id)
|
||||
|
||||
async def get_by_id(self, entity_id: int) -> Optional[Entity]:
|
||||
"""Get entity by numeric ID.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, entity_id)
|
||||
|
||||
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
||||
"""Get entity by permalink.
|
||||
|
||||
@@ -63,6 +77,127 @@ class EntityRepository(Repository[Entity]):
|
||||
)
|
||||
return await self.find_one(query)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Lightweight methods for permalink resolution (no eager loading)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def permalink_exists(self, permalink: str) -> bool:
|
||||
"""Check if a permalink exists without loading the full entity.
|
||||
|
||||
This is much faster than get_by_permalink() as it skips eager loading
|
||||
of observations and relations. Use for existence checks in bulk operations.
|
||||
|
||||
Args:
|
||||
permalink: Permalink to check
|
||||
|
||||
Returns:
|
||||
True if permalink exists, False otherwise
|
||||
"""
|
||||
query = select(Entity.id).where(Entity.permalink == permalink).limit(1)
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_file_path_for_permalink(self, permalink: str) -> Optional[str]:
|
||||
"""Get the file_path for a permalink without loading the full entity.
|
||||
|
||||
Use when you only need the file_path, not the full entity with relations.
|
||||
|
||||
Args:
|
||||
permalink: Permalink to look up
|
||||
|
||||
Returns:
|
||||
file_path string if found, None otherwise
|
||||
"""
|
||||
query = select(Entity.file_path).where(Entity.permalink == permalink)
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_permalink_for_file_path(self, file_path: Union[Path, str]) -> Optional[str]:
|
||||
"""Get the permalink for a file_path without loading the full entity.
|
||||
|
||||
Use when you only need the permalink, not the full entity with relations.
|
||||
|
||||
Args:
|
||||
file_path: File path to look up
|
||||
|
||||
Returns:
|
||||
permalink string if found, None otherwise
|
||||
"""
|
||||
query = select(Entity.permalink).where(Entity.file_path == Path(file_path).as_posix())
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all_permalinks(self) -> List[str]:
|
||||
"""Get all permalinks for this project.
|
||||
|
||||
Optimized for bulk operations - returns only permalink strings
|
||||
without loading entities or relationships.
|
||||
|
||||
Returns:
|
||||
List of all permalinks in the project
|
||||
"""
|
||||
query = select(Entity.permalink)
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_permalink_to_file_path_map(self) -> dict[str, str]:
|
||||
"""Get a mapping of permalink -> file_path for all entities.
|
||||
|
||||
Optimized for bulk permalink resolution - loads minimal data in one query.
|
||||
|
||||
Returns:
|
||||
Dict mapping permalink to file_path
|
||||
"""
|
||||
query = select(Entity.permalink, Entity.file_path)
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return {row.permalink: row.file_path for row in result.all()}
|
||||
|
||||
async def get_file_path_to_permalink_map(self) -> dict[str, str]:
|
||||
"""Get a mapping of file_path -> permalink for all entities.
|
||||
|
||||
Optimized for bulk permalink resolution - loads minimal data in one query.
|
||||
|
||||
Returns:
|
||||
Dict mapping file_path to permalink
|
||||
"""
|
||||
query = select(Entity.file_path, Entity.permalink)
|
||||
query = self._add_project_filter(query)
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return {row.file_path: row.permalink for row in result.all()}
|
||||
|
||||
async def get_by_file_paths(
|
||||
self, session: AsyncSession, file_paths: Sequence[Union[Path, str]]
|
||||
) -> List[Row[Any]]:
|
||||
"""Get file paths and checksums for multiple entities (optimized for change detection).
|
||||
|
||||
Only queries file_path and checksum columns, skips loading full entities and relationships.
|
||||
This is much faster than loading complete Entity objects when you only need checksums.
|
||||
|
||||
Args:
|
||||
session: Database session to use for the query
|
||||
file_paths: List of file paths to query
|
||||
|
||||
Returns:
|
||||
List of (file_path, checksum) tuples for matching entities
|
||||
"""
|
||||
if not file_paths:
|
||||
return []
|
||||
|
||||
# Convert all paths to POSIX strings for consistent comparison
|
||||
posix_paths = [Path(fp).as_posix() for fp in file_paths]
|
||||
|
||||
# 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)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.all())
|
||||
|
||||
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
|
||||
"""Find entities with the given checksum.
|
||||
|
||||
@@ -80,6 +215,34 @@ class EntityRepository(Repository[Entity]):
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def find_by_checksums(self, checksums: Sequence[str]) -> Sequence[Entity]:
|
||||
"""Find entities with any of the given checksums (batch query for move detection).
|
||||
|
||||
This is a batch-optimized version of find_by_checksum() that queries multiple checksums
|
||||
in a single database query. Used for efficient move detection in cloud indexing.
|
||||
|
||||
Performance: For 1000 new files, this makes 1 query vs 1000 individual queries (~100x faster).
|
||||
|
||||
Example:
|
||||
When processing new files, we check if any are actually moved files by finding
|
||||
entities with matching checksums at different paths.
|
||||
|
||||
Args:
|
||||
checksums: List of file content checksums to search for
|
||||
|
||||
Returns:
|
||||
Sequence of entities with matching checksums (may be empty).
|
||||
Multiple entities may have the same checksum if files were copied.
|
||||
"""
|
||||
if not checksums:
|
||||
return []
|
||||
|
||||
# Query: SELECT * FROM entities WHERE checksum IN (checksum1, checksum2, ...)
|
||||
query = self.select().where(Entity.checksum.in_(checksums))
|
||||
# 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())
|
||||
|
||||
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
|
||||
"""Delete entity with the provided file_path.
|
||||
|
||||
@@ -155,8 +318,13 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
except IntegrityError as e:
|
||||
# Check if this is a FOREIGN KEY constraint failure
|
||||
# SQLite: "FOREIGN KEY constraint failed"
|
||||
# Postgres: "violates foreign key constraint"
|
||||
error_str = str(e)
|
||||
if "FOREIGN KEY constraint failed" in error_str:
|
||||
if (
|
||||
"FOREIGN KEY constraint failed" in error_str
|
||||
or "violates foreign key constraint" in error_str
|
||||
):
|
||||
# Import locally to avoid circular dependency (repository -> services -> repository)
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
|
||||
@@ -310,5 +478,26 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
# Insert with unique permalink
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as e:
|
||||
# Check if this is a FOREIGN KEY constraint failure
|
||||
# SQLite: "FOREIGN KEY constraint failed"
|
||||
# Postgres: "violates foreign key constraint"
|
||||
error_str = str(e)
|
||||
if (
|
||||
"FOREIGN KEY constraint failed" in error_str
|
||||
or "violates foreign key constraint" in error_str
|
||||
):
|
||||
# Import locally to avoid circular dependency (repository -> services -> repository)
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
|
||||
# Project doesn't exist in database - this is a fatal sync error
|
||||
raise SyncFatalError(
|
||||
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
|
||||
return entity
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Dict, List, Sequence
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""PostgreSQL tsvector-based search repository implementation."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
class PostgresSearchRepository(SearchRepositoryBase):
|
||||
"""PostgreSQL tsvector implementation of search repository.
|
||||
|
||||
Uses PostgreSQL's full-text search capabilities with:
|
||||
- tsvector for document representation
|
||||
- tsquery for query representation
|
||||
- GIN indexes for performance
|
||||
- ts_rank() function for relevance scoring
|
||||
- JSONB containment operators for metadata search
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create Postgres table with tsvector column and GIN indexes.
|
||||
|
||||
Note: This is handled by Alembic migrations. This method is a no-op
|
||||
for Postgres as the schema is created via migrations.
|
||||
"""
|
||||
logger.info("PostgreSQL search index initialization handled by migrations")
|
||||
# Table creation is done via Alembic migrations
|
||||
# This includes:
|
||||
# - CREATE TABLE search_index (...)
|
||||
# - ADD COLUMN textsearchable_index_col tsvector GENERATED ALWAYS AS (...)
|
||||
# - CREATE INDEX USING GIN on textsearchable_index_col
|
||||
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
|
||||
pass
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for tsquery format.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (:* operator)
|
||||
|
||||
Returns:
|
||||
Formatted search term for tsquery
|
||||
|
||||
For Postgres:
|
||||
- Boolean operators are converted to tsquery format (&, |, !)
|
||||
- Prefix matching uses the :* operator
|
||||
- Terms are sanitized to prevent tsquery syntax errors
|
||||
"""
|
||||
# Check for explicit boolean operators
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, prepare single term
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Convert Boolean query to tsquery format.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "coffee AND brewing" or "(pour OR french) AND press"
|
||||
|
||||
Returns:
|
||||
tsquery-formatted string with & (AND), | (OR), ! (NOT) operators
|
||||
|
||||
Examples:
|
||||
"coffee AND brewing" -> "coffee & brewing"
|
||||
"(pour OR french) AND press" -> "(pour | french) & press"
|
||||
"coffee NOT decaf" -> "coffee & !decaf"
|
||||
"""
|
||||
# Replace Boolean operators with tsquery operators
|
||||
# Keep parentheses for grouping
|
||||
result = query
|
||||
result = re.sub(r"\bAND\b", "&", result)
|
||||
result = re.sub(r"\bOR\b", "|", result)
|
||||
# NOT must be converted to "& !" and the ! must be attached to the following term
|
||||
# "Python NOT Django" -> "Python & !Django"
|
||||
result = re.sub(r"\bNOT\s+", "& !", result)
|
||||
|
||||
return result
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term for tsquery.
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (:* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term for tsquery
|
||||
|
||||
For Postgres tsquery:
|
||||
- Multi-word queries become "word1 & word2"
|
||||
- Prefix matching uses ":*" suffix (e.g., "coff:*")
|
||||
- Special characters that need escaping: & | ! ( ) :
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a wildcard pattern
|
||||
if "*" in term:
|
||||
# Replace * with :* for Postgres prefix matching
|
||||
return term.replace("*", ":*")
|
||||
|
||||
# Remove tsquery special characters from the search term
|
||||
# These characters have special meaning in tsquery and cause syntax errors
|
||||
# if not used as operators
|
||||
special_chars = ["&", "|", "!", "(", ")", ":"]
|
||||
cleaned_term = term
|
||||
for char in special_chars:
|
||||
cleaned_term = cleaned_term.replace(char, " ")
|
||||
|
||||
# Handle multi-word queries
|
||||
if " " in cleaned_term:
|
||||
words = [w for w in cleaned_term.split() if w.strip()]
|
||||
if not words:
|
||||
# All characters were special chars, search won't match anything
|
||||
# Return a safe search term that won't cause syntax errors
|
||||
return "NOSPECIALCHARS:*"
|
||||
if is_prefix:
|
||||
# Add prefix matching to each word
|
||||
prepared_words = [f"{word}:*" for word in words]
|
||||
else:
|
||||
prepared_words = words
|
||||
# Join with AND operator
|
||||
return " & ".join(prepared_words)
|
||||
|
||||
# Single word
|
||||
cleaned_term = cleaned_term.strip()
|
||||
if not cleaned_term:
|
||||
return "NOSPECIALCHARS:*"
|
||||
if is_prefix:
|
||||
return f"{cleaned_term}:*"
|
||||
else:
|
||||
return cleaned_term
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using PostgreSQL tsvector."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
|
||||
# Handle text search for title and content using tsvector
|
||||
if search_text:
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions
|
||||
pass
|
||||
else:
|
||||
# Prepare search term for tsquery
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
# Use @@ operator for tsvector matching
|
||||
conditions.append("textsearchable_index_col @@ to_tsquery('english', :text)")
|
||||
|
||||
# Handle title search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("to_tsvector('english', title) @@ to_tsquery('english', :title_text)")
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle permalink pattern match
|
||||
if permalink_match:
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
# Use LIKE for pattern matching in Postgres
|
||||
# Convert * to % for SQL LIKE
|
||||
permalink_pattern = permalink_text.replace("*", "%")
|
||||
params["permalink"] = permalink_pattern
|
||||
conditions.append("permalink LIKE :permalink")
|
||||
else:
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle search item type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle entity type filter using JSONB containment
|
||||
if types:
|
||||
# Use JSONB @> operator for efficient containment queries
|
||||
type_conditions = []
|
||||
for entity_type in types:
|
||||
# Create JSONB containment condition for each type
|
||||
type_conditions.append(f'metadata @> \'{{"entity_type": "{entity_type}"}}\'')
|
||||
conditions.append(f"({' OR '.join(type_conditions)})")
|
||||
|
||||
# Handle date filter
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("created_at > :after_date")
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
|
||||
# set limit and offset
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# Build SQL with ts_rank() for scoring
|
||||
# Note: If no text search, score will be NULL, so we use COALESCE to default to 0
|
||||
if search_text and search_text.strip() and search_text.strip() != "*":
|
||||
score_expr = "ts_rank(textsearchable_index_col, to_tsquery('english', :text))"
|
||||
else:
|
||||
score_expr = "0"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
{score_expr} as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score DESC, id ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
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
|
||||
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
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=float(row.score) if row.score else 0.0,
|
||||
metadata=(
|
||||
row.metadata
|
||||
if isinstance(row.metadata, dict)
|
||||
else (json.loads(row.metadata) if row.metadata else {})
|
||||
),
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert(serialize_json=True)
|
||||
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
|
||||
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 (id, type, project_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
content_stems = EXCLUDED.content_stems,
|
||||
content_snippet = EXCLUDED.content_snippet,
|
||||
permalink = EXCLUDED.permalink,
|
||||
file_path = EXCLUDED.file_path,
|
||||
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_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence, Union
|
||||
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
@@ -23,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
|
||||
@@ -31,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.
|
||||
|
||||
@@ -49,6 +62,18 @@ class ProjectRepository(Repository[Project]):
|
||||
query = self.select().where(Project.path == Path(path).as_posix())
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_id(self, project_id: int) -> Optional[Project]:
|
||||
"""Get project by numeric ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
Project if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, project_id)
|
||||
|
||||
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))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Repository for managing Relation objects."""
|
||||
|
||||
from sqlalchemy import and_, delete
|
||||
from typing import Sequence, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from sqlalchemy import and_, delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
@@ -86,5 +88,59 @@ class RelationRepository(Repository[Relation]):
|
||||
result = await self.execute_query(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int:
|
||||
"""Bulk insert relations, ignoring duplicates.
|
||||
|
||||
Uses ON CONFLICT DO NOTHING to skip relations that would violate the
|
||||
unique constraint on (from_id, to_name, relation_type). This is useful
|
||||
for bulk operations where the same link may appear multiple times in
|
||||
a document.
|
||||
|
||||
Works with both SQLite and PostgreSQL dialects.
|
||||
|
||||
Args:
|
||||
relations: List of Relation objects to insert
|
||||
|
||||
Returns:
|
||||
Number of relations actually inserted (excludes duplicates)
|
||||
"""
|
||||
if not relations:
|
||||
return 0
|
||||
|
||||
# Convert Relation objects to dicts for insert
|
||||
values = [
|
||||
{
|
||||
"project_id": r.project_id if r.project_id else self.project_id,
|
||||
"from_id": r.from_id,
|
||||
"to_id": r.to_id,
|
||||
"to_name": r.to_name,
|
||||
"relation_type": r.relation_type,
|
||||
"context": r.context,
|
||||
}
|
||||
for r in relations
|
||||
]
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Check dialect to use appropriate insert
|
||||
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
|
||||
|
||||
if dialect_name == "postgresql":
|
||||
# PostgreSQL: use RETURNING to count inserted rows
|
||||
# (rowcount is 0 for ON CONFLICT DO NOTHING)
|
||||
stmt = (
|
||||
pg_insert(Relation)
|
||||
.values(values)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(Relation.id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return len(result.fetchall())
|
||||
else:
|
||||
# SQLite: rowcount works correctly
|
||||
stmt = sqlite_insert(Relation).values(values)
|
||||
stmt = stmt.on_conflict_do_nothing()
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount if result.rowcount > 0 else 0
|
||||
|
||||
def get_load_options(self) -> List[LoaderOption]:
|
||||
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Search index data structures."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchIndexRow:
|
||||
"""Search result with score and metadata."""
|
||||
|
||||
project_id: int
|
||||
id: int
|
||||
type: str
|
||||
file_path: str
|
||||
|
||||
# date values
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
permalink: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# assigned in result
|
||||
score: Optional[float] = None
|
||||
|
||||
# Type-specific fields
|
||||
title: Optional[str] = None # entity
|
||||
content_stems: Optional[str] = None # entity, observation
|
||||
content_snippet: Optional[str] = None # entity, observation
|
||||
entity_id: Optional[int] = None # observations
|
||||
category: Optional[str] = None # observations
|
||||
from_id: Optional[int] = None # relations
|
||||
to_id: Optional[int] = None # relations
|
||||
relation_type: Optional[str] = None # relations
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return self.content_snippet
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
"""Extract directory part from file_path.
|
||||
|
||||
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
||||
For a file at root level "README.md", returns "/"
|
||||
"""
|
||||
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
||||
return ""
|
||||
|
||||
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
||||
normalized_path = Path(self.file_path).as_posix()
|
||||
|
||||
# Split the path by slashes
|
||||
parts = normalized_path.split("/")
|
||||
|
||||
# If there's only one part (e.g., "README.md"), it's at the root
|
||||
if len(parts) <= 1:
|
||||
return "/"
|
||||
|
||||
# Join all parts except the last one (filename)
|
||||
directory_path = "/".join(parts[:-1])
|
||||
return f"/{directory_path}"
|
||||
|
||||
def to_insert(self, serialize_json: bool = True):
|
||||
"""Convert to dict for database insertion.
|
||||
|
||||
Args:
|
||||
serialize_json: If True, converts metadata dict to JSON string (for SQLite).
|
||||
If False, keeps metadata as dict (for Postgres JSONB).
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content_stems": self.content_stems,
|
||||
"content_snippet": self.content_snippet,
|
||||
"permalink": self.permalink,
|
||||
"file_path": self.file_path,
|
||||
"type": self.type,
|
||||
"metadata": json.dumps(self.metadata)
|
||||
if serialize_json and self.metadata
|
||||
else self.metadata,
|
||||
"from_id": self.from_id,
|
||||
"to_id": self.to_id,
|
||||
"relation_type": self.relation_type,
|
||||
"entity_id": self.entity_id,
|
||||
"category": self.category,
|
||||
"created_at": self.created_at if self.created_at else None,
|
||||
"updated_at": self.updated_at if self.updated_at else None,
|
||||
"project_id": self.project_id,
|
||||
}
|
||||
@@ -1,365 +1,35 @@
|
||||
"""Repository for search operations."""
|
||||
"""Repository for search operations.
|
||||
|
||||
This module provides the search repository interface.
|
||||
The actual repository implementations are backend-specific:
|
||||
- SQLiteSearchRepository: Uses FTS5 virtual tables
|
||||
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.config import ConfigManager, DatabaseBackend
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchIndexRow:
|
||||
"""Search result with score and metadata."""
|
||||
class SearchRepository(Protocol):
|
||||
"""Protocol defining the search repository interface.
|
||||
|
||||
Both SQLite and Postgres implementations must satisfy this protocol.
|
||||
"""
|
||||
|
||||
project_id: int
|
||||
id: int
|
||||
type: str
|
||||
file_path: str
|
||||
|
||||
# date values
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
permalink: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# assigned in result
|
||||
score: Optional[float] = None
|
||||
|
||||
# Type-specific fields
|
||||
title: Optional[str] = None # entity
|
||||
content_stems: Optional[str] = None # entity, observation
|
||||
content_snippet: Optional[str] = None # entity, observation
|
||||
entity_id: Optional[int] = None # observations
|
||||
category: Optional[str] = None # observations
|
||||
from_id: Optional[int] = None # relations
|
||||
to_id: Optional[int] = None # relations
|
||||
relation_type: Optional[str] = None # relations
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return self.content_snippet
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
"""Extract directory part from file_path.
|
||||
|
||||
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
||||
For a file at root level "README.md", returns "/"
|
||||
"""
|
||||
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
||||
return ""
|
||||
|
||||
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
||||
normalized_path = Path(self.file_path).as_posix()
|
||||
|
||||
# Split the path by slashes
|
||||
parts = normalized_path.split("/")
|
||||
|
||||
# If there's only one part (e.g., "README.md"), it's at the root
|
||||
if len(parts) <= 1:
|
||||
return "/"
|
||||
|
||||
# Join all parts except the last one (filename)
|
||||
directory_path = "/".join(parts[:-1])
|
||||
return f"/{directory_path}"
|
||||
|
||||
def to_insert(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content_stems": self.content_stems,
|
||||
"content_snippet": self.content_snippet,
|
||||
"permalink": self.permalink,
|
||||
"file_path": self.file_path,
|
||||
"type": self.type,
|
||||
"metadata": json.dumps(self.metadata),
|
||||
"from_id": self.from_id,
|
||||
"to_id": self.to_id,
|
||||
"relation_type": self.relation_type,
|
||||
"entity_id": self.entity_id,
|
||||
"category": self.category,
|
||||
"created_at": self.created_at if self.created_at else None,
|
||||
"updated_at": self.updated_at if self.updated_at else None,
|
||||
"project_id": self.project_id,
|
||||
}
|
||||
|
||||
|
||||
class SearchRepository:
|
||||
"""Repository for search index operations."""
|
||||
|
||||
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
||||
"""Initialize with session maker and project_id filter.
|
||||
|
||||
Args:
|
||||
session_maker: SQLAlchemy session maker
|
||||
project_id: Project ID to filter all operations by
|
||||
|
||||
Raises:
|
||||
ValueError: If project_id is None or invalid
|
||||
"""
|
||||
if project_id is None or project_id <= 0: # pragma: no cover
|
||||
raise ValueError("A valid project_id is required for SearchRepository")
|
||||
|
||||
self.session_maker = session_maker
|
||||
self.project_id = project_id
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create or recreate the search index."""
|
||||
logger.info("Initializing search index")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Prepare a Boolean query by quoting individual terms while preserving operators.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
|
||||
|
||||
Returns:
|
||||
A properly formatted Boolean query with quoted terms that need quoting
|
||||
"""
|
||||
# Define Boolean operators and their boundaries
|
||||
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
|
||||
|
||||
# Split the query by Boolean operators, keeping the operators
|
||||
parts = re.split(boolean_pattern, query)
|
||||
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# If it's a Boolean operator, keep it as is
|
||||
if part in ["AND", "OR", "NOT"]:
|
||||
processed_parts.append(part)
|
||||
else:
|
||||
# Handle parentheses specially - they should be preserved for grouping
|
||||
if "(" in part or ")" in part:
|
||||
# Parse parenthetical expressions carefully
|
||||
processed_part = self._prepare_parenthetical_term(part)
|
||||
processed_parts.append(processed_part)
|
||||
else:
|
||||
# This is a search term - for Boolean queries, don't add prefix wildcards
|
||||
prepared_term = self._prepare_single_term(part, is_prefix=False)
|
||||
processed_parts.append(prepared_term)
|
||||
|
||||
return " ".join(processed_parts)
|
||||
|
||||
def _prepare_parenthetical_term(self, term: str) -> str:
|
||||
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
|
||||
|
||||
Args:
|
||||
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
|
||||
|
||||
Returns:
|
||||
A properly formatted term with parentheses preserved
|
||||
"""
|
||||
# Handle terms that start/end with parentheses but may contain quotable content
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(term):
|
||||
if term[i] in "()":
|
||||
# Preserve parentheses as-is
|
||||
result += term[i]
|
||||
i += 1
|
||||
else:
|
||||
# Find the next parenthesis or end of string
|
||||
start = i
|
||||
while i < len(term) and term[i] not in "()":
|
||||
i += 1
|
||||
|
||||
# Extract the content between parentheses
|
||||
content = term[start:i].strip()
|
||||
if content:
|
||||
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
|
||||
# but don't quote if it's just simple words
|
||||
if self._needs_quoting(content):
|
||||
escaped_content = content.replace('"', '""')
|
||||
result += f'"{escaped_content}"'
|
||||
else:
|
||||
result += content
|
||||
|
||||
return result
|
||||
|
||||
def _needs_quoting(self, term: str) -> bool:
|
||||
"""Check if a term needs to be quoted for FTS5 safety.
|
||||
|
||||
Args:
|
||||
term: The term to check
|
||||
|
||||
Returns:
|
||||
True if the term should be quoted
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return False
|
||||
|
||||
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
|
||||
needs_quoting_chars = [
|
||||
" ",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"/",
|
||||
"-",
|
||||
"'",
|
||||
'"',
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
return any(c in term for c in needs_quoting_chars)
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term (no Boolean operators).
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
return term
|
||||
|
||||
# Characters that can cause FTS5 syntax errors when used as operators
|
||||
# We're more conservative here - only quote when we detect problematic patterns
|
||||
problematic_chars = [
|
||||
'"',
|
||||
"'",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
||||
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
||||
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
||||
|
||||
# Check if term needs quoting
|
||||
has_problematic = any(c in term for c in problematic_chars)
|
||||
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
||||
|
||||
if has_problematic or has_spaces_or_special:
|
||||
# Handle multi-word queries differently from special character queries
|
||||
if " " in term and not any(c in term for c in problematic_chars):
|
||||
# Check if any individual word contains special characters that need quoting
|
||||
words = term.strip().split()
|
||||
has_special_in_words = any(
|
||||
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
||||
)
|
||||
|
||||
if not has_special_in_words:
|
||||
# For multi-word queries with simple words (like "emoji unicode"),
|
||||
# use boolean AND to handle word order variations
|
||||
if is_prefix:
|
||||
# Add prefix wildcard to each word for better matching
|
||||
prepared_words = [f"{word}*" for word in words if word]
|
||||
else:
|
||||
prepared_words = words
|
||||
term = " AND ".join(prepared_words)
|
||||
else:
|
||||
# If any word has special characters, quote the entire phrase
|
||||
escaped_term = term.replace('"', '""')
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
else:
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
elif is_prefix:
|
||||
# Only add wildcard for simple terms without special characters
|
||||
term = f"{term}*"
|
||||
|
||||
return term
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, process as Boolean query
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, use the single term preparation logic
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
async def init_search_index(self) -> None:
|
||||
"""Initialize the search index schema."""
|
||||
...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -373,267 +43,52 @@ class SearchRepository:
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
"""Search across indexed content."""
|
||||
...
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index a single item."""
|
||||
...
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("title MATCH :title_text")
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a batch."""
|
||||
...
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
async def delete_by_permalink(self, permalink: str) -> None:
|
||||
"""Delete item by permalink."""
|
||||
...
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
||||
# GLOB patterns need to preserve their syntax
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
async def delete_by_entity_id(self, entity_id: int) -> None:
|
||||
"""Delete items by entity ID."""
|
||||
...
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
async def execute_query(self, query, params: dict) -> Result:
|
||||
"""Execute a raw SQL query."""
|
||||
...
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
def create_search_repository(
|
||||
session_maker: async_sessionmaker[AsyncSession], project_id: int
|
||||
) -> SearchRepository:
|
||||
"""Factory function to create the appropriate search repository based on database backend.
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
Args:
|
||||
session_maker: SQLAlchemy async session maker
|
||||
project_id: Project ID for the repository
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
Returns:
|
||||
SearchRepository: Backend-appropriate search repository instance
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
if config.database_backend == DatabaseBackend.POSTGRES:
|
||||
return PostgresSearchRepository(session_maker, project_id=project_id)
|
||||
else:
|
||||
return SQLiteSearchRepository(session_maker, project_id=project_id)
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
bm25(search_index) as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"FTS5 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
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=row.score,
|
||||
metadata=json.loads(row.metadata),
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def index_item(
|
||||
self,
|
||||
search_index_row: SearchIndexRow,
|
||||
):
|
||||
"""Index or update a single item."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Delete existing record if any
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": search_index_row.permalink, "project_id": self.project_id},
|
||||
)
|
||||
|
||||
# Prepare data for insert with project_id
|
||||
insert_data = search_index_row.to_insert()
|
||||
insert_data["project_id"] = self.project_id
|
||||
|
||||
# Insert new record
|
||||
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
|
||||
)
|
||||
"""),
|
||||
insert_data,
|
||||
)
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]):
|
||||
"""Index multiple items in a single batch operation.
|
||||
|
||||
Note: This method assumes that any existing records for the entity_id
|
||||
have already been deleted (typically via delete_by_entity_id).
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Prepare all insert data with project_id
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert()
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Batch insert all records using executemany
|
||||
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
|
||||
)
|
||||
"""),
|
||||
insert_data_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_entity_id(self, entity_id: int):
|
||||
"""Delete an item from the search index by entity_id."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
|
||||
),
|
||||
{"entity_id": entity_id, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str):
|
||||
"""Delete an item from the search index."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": permalink, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
query: Executable,
|
||||
params: Dict[str, Any],
|
||||
) -> Result[Any]:
|
||||
"""Execute a query asynchronously."""
|
||||
# logger.debug(f"Executing query: {query}, params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
start_time = time.perf_counter()
|
||||
result = await session.execute(query, params)
|
||||
end_time = time.perf_counter()
|
||||
elapsed_time = end_time - start_time
|
||||
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
||||
return result
|
||||
__all__ = [
|
||||
"SearchRepository",
|
||||
"SearchIndexRow",
|
||||
"create_search_repository",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Abstract base class for search repository implementations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
|
||||
|
||||
class SearchRepositoryBase(ABC):
|
||||
"""Abstract base class for backend-specific search repository implementations.
|
||||
|
||||
This class defines the common interface that all search repositories must implement,
|
||||
regardless of whether they use SQLite FTS5 or Postgres tsvector for full-text search.
|
||||
|
||||
Concrete implementations:
|
||||
- SQLiteSearchRepository: Uses FTS5 virtual tables with MATCH queries
|
||||
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
|
||||
"""
|
||||
|
||||
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
||||
"""Initialize with session maker and project_id filter.
|
||||
|
||||
Args:
|
||||
session_maker: SQLAlchemy session maker
|
||||
project_id: Project ID to filter all operations by
|
||||
|
||||
Raises:
|
||||
ValueError: If project_id is None or invalid
|
||||
"""
|
||||
if project_id is None or project_id <= 0: # pragma: no cover
|
||||
raise ValueError("A valid project_id is required for SearchRepository")
|
||||
|
||||
self.session_maker = session_maker
|
||||
self.project_id = project_id
|
||||
|
||||
@abstractmethod
|
||||
async def init_search_index(self) -> None:
|
||||
"""Create or recreate the search index.
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: CREATE VIRTUAL TABLE using FTS5
|
||||
- Postgres: CREATE TABLE with tsvector column and GIN indexes
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for backend-specific query syntax.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability
|
||||
|
||||
Returns:
|
||||
Formatted search term for the backend
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: Quotes FTS5 special characters, adds * wildcards
|
||||
- Postgres: Converts to tsquery syntax with :* prefix operator
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Args:
|
||||
search_text: Full-text search across title and content
|
||||
permalink: Exact permalink match
|
||||
permalink_match: Permalink pattern match (supports *)
|
||||
title: Title search
|
||||
types: Filter by entity types (from metadata.entity_type)
|
||||
after_date: Filter by created_at > after_date
|
||||
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
|
||||
limit: Maximum results to return
|
||||
offset: Number of results to skip
|
||||
|
||||
Returns:
|
||||
List of SearchIndexRow results with relevance scores
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: Uses MATCH operator and bm25() for scoring
|
||||
- Postgres: Uses @@ operator and ts_rank() for scoring
|
||||
"""
|
||||
pass
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index or update a single item.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL INSERT.
|
||||
"""
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Delete existing record if any
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": search_index_row.permalink, "project_id": self.project_id},
|
||||
)
|
||||
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data = search_index_row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
|
||||
# Insert new record
|
||||
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
|
||||
)
|
||||
"""),
|
||||
insert_data,
|
||||
)
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a single batch operation.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL INSERT.
|
||||
|
||||
Note: This method assumes that any existing records for the entity_id
|
||||
have already been deleted (typically via delete_by_entity_id).
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Batch insert all records using executemany
|
||||
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
|
||||
)
|
||||
"""),
|
||||
insert_data_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_entity_id(self, entity_id: int) -> None:
|
||||
"""Delete all search index entries for an entity.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL DELETE.
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
|
||||
),
|
||||
{"entity_id": entity_id, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str) -> None:
|
||||
"""Delete a search index entry by permalink.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL DELETE.
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": permalink, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
query: Executable,
|
||||
params: Dict[str, Any],
|
||||
) -> Result[Any]:
|
||||
"""Execute a query asynchronously.
|
||||
|
||||
This implementation is shared across backends for utility query execution.
|
||||
"""
|
||||
import time
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
start_time = time.perf_counter()
|
||||
result = await session.execute(query, params)
|
||||
end_time = time.perf_counter()
|
||||
elapsed_time = end_time - start_time
|
||||
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
||||
return result
|
||||
@@ -0,0 +1,439 @@
|
||||
"""SQLite FTS5-based search repository implementation."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
"""SQLite FTS5 implementation of search repository.
|
||||
|
||||
Uses SQLite's FTS5 virtual tables for full-text search with:
|
||||
- MATCH operator for queries
|
||||
- bm25() function for relevance scoring
|
||||
- Special character quoting for syntax safety
|
||||
- Prefix wildcard matching with *
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create FTS5 virtual table for search.
|
||||
|
||||
Note: Drops any existing search_index table first to ensure FTS5 virtual table creation.
|
||||
This is necessary because Base.metadata.create_all() might create a regular table.
|
||||
"""
|
||||
logger.info("Initializing SQLite FTS5 search index")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Drop any existing regular or virtual table first
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_index"))
|
||||
# Create FTS5 virtual table
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Prepare a Boolean query by quoting individual terms while preserving operators.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
|
||||
|
||||
Returns:
|
||||
A properly formatted Boolean query with quoted terms that need quoting
|
||||
"""
|
||||
# Define Boolean operators and their boundaries
|
||||
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
|
||||
|
||||
# Split the query by Boolean operators, keeping the operators
|
||||
parts = re.split(boolean_pattern, query)
|
||||
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# If it's a Boolean operator, keep it as is
|
||||
if part in ["AND", "OR", "NOT"]:
|
||||
processed_parts.append(part)
|
||||
else:
|
||||
# Handle parentheses specially - they should be preserved for grouping
|
||||
if "(" in part or ")" in part:
|
||||
# Parse parenthetical expressions carefully
|
||||
processed_part = self._prepare_parenthetical_term(part)
|
||||
processed_parts.append(processed_part)
|
||||
else:
|
||||
# This is a search term - for Boolean queries, don't add prefix wildcards
|
||||
prepared_term = self._prepare_single_term(part, is_prefix=False)
|
||||
processed_parts.append(prepared_term)
|
||||
|
||||
return " ".join(processed_parts)
|
||||
|
||||
def _prepare_parenthetical_term(self, term: str) -> str:
|
||||
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
|
||||
|
||||
Args:
|
||||
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
|
||||
|
||||
Returns:
|
||||
A properly formatted term with parentheses preserved
|
||||
"""
|
||||
# Handle terms that start/end with parentheses but may contain quotable content
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(term):
|
||||
if term[i] in "()":
|
||||
# Preserve parentheses as-is
|
||||
result += term[i]
|
||||
i += 1
|
||||
else:
|
||||
# Find the next parenthesis or end of string
|
||||
start = i
|
||||
while i < len(term) and term[i] not in "()":
|
||||
i += 1
|
||||
|
||||
# Extract the content between parentheses
|
||||
content = term[start:i].strip()
|
||||
if content:
|
||||
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
|
||||
# but don't quote if it's just simple words
|
||||
if self._needs_quoting(content):
|
||||
escaped_content = content.replace('"', '""')
|
||||
result += f'"{escaped_content}"'
|
||||
else:
|
||||
result += content
|
||||
|
||||
return result
|
||||
|
||||
def _needs_quoting(self, term: str) -> bool:
|
||||
"""Check if a term needs to be quoted for FTS5 safety.
|
||||
|
||||
Args:
|
||||
term: The term to check
|
||||
|
||||
Returns:
|
||||
True if the term should be quoted
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return False
|
||||
|
||||
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
|
||||
needs_quoting_chars = [
|
||||
" ",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"/",
|
||||
"-",
|
||||
"'",
|
||||
'"',
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
return any(c in term for c in needs_quoting_chars)
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term (no Boolean operators).
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
return term
|
||||
|
||||
# Characters that can cause FTS5 syntax errors when used as operators
|
||||
# We're more conservative here - only quote when we detect problematic patterns
|
||||
problematic_chars = [
|
||||
'"',
|
||||
"'",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
||||
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
||||
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
||||
|
||||
# Check if term needs quoting
|
||||
has_problematic = any(c in term for c in problematic_chars)
|
||||
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
||||
|
||||
if has_problematic or has_spaces_or_special:
|
||||
# Handle multi-word queries differently from special character queries
|
||||
if " " in term and not any(c in term for c in problematic_chars):
|
||||
# Check if any individual word contains special characters that need quoting
|
||||
words = term.strip().split()
|
||||
has_special_in_words = any(
|
||||
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
||||
)
|
||||
|
||||
if not has_special_in_words:
|
||||
# For multi-word queries with simple words (like "emoji unicode"),
|
||||
# use boolean AND to handle word order variations
|
||||
if is_prefix:
|
||||
# Add prefix wildcard to each word for better matching
|
||||
prepared_words = [f"{word}*" for word in words if word]
|
||||
else:
|
||||
prepared_words = words
|
||||
term = " AND ".join(prepared_words)
|
||||
else:
|
||||
# If any word has special characters, quote the entire phrase
|
||||
escaped_term = term.replace('"', '""')
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
else:
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
elif is_prefix:
|
||||
# Only add wildcard for simple terms without special characters
|
||||
term = f"{term}*"
|
||||
|
||||
return term
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, process as Boolean query
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, use the single term preparation logic
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using SQLite FTS5."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("title MATCH :title_text")
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
||||
# GLOB patterns need to preserve their syntax
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
bm25(search_index) as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"FTS5 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
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=row.score,
|
||||
metadata=json.loads(row.metadata) if row.metadata else {},
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -21,13 +21,38 @@ from typing import List, Optional, Annotated, Dict
|
||||
from annotated_types import MinLen, MaxLen
|
||||
from dateparser import parse
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
def has_valid_file_extension(filename: str) -> bool:
|
||||
"""Check if a filename has a valid file extension recognized by mimetypes.
|
||||
|
||||
This is used to determine whether to split the extension when processing
|
||||
titles in kebab_filenames mode. Prevents treating periods in version numbers
|
||||
or decimals as file extensions.
|
||||
|
||||
Args:
|
||||
filename: The filename to check
|
||||
|
||||
Returns:
|
||||
True if the filename has a recognized file extension, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> has_valid_file_extension("document.md")
|
||||
True
|
||||
>>> has_valid_file_extension("Version 2.0.0")
|
||||
False
|
||||
>>> has_valid_file_extension("image.png")
|
||||
True
|
||||
"""
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return mime_type is not None
|
||||
|
||||
|
||||
def to_snake_case(name: str) -> str:
|
||||
"""Convert a string to snake_case.
|
||||
|
||||
@@ -158,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
|
||||
]
|
||||
|
||||
|
||||
@@ -232,12 +257,17 @@ class Entity(BaseModel):
|
||||
use_kebab_case = app_config.kebab_filenames
|
||||
|
||||
if use_kebab_case:
|
||||
fixed_title = generate_permalink(file_path=fixed_title, split_extension=False)
|
||||
# Convert to kebab-case: lowercase with hyphens, preserving periods in version numbers
|
||||
# generate_permalink() uses mimetypes to detect real file extensions and only splits
|
||||
# them off, avoiding misinterpreting periods in version numbers as extensions
|
||||
has_extension = has_valid_file_extension(fixed_title)
|
||||
fixed_title = generate_permalink(file_path=fixed_title, split_extension=has_extension)
|
||||
|
||||
return fixed_title
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def file_path(self):
|
||||
def file_path(self) -> str:
|
||||
"""Get the file path for this entity based on its permalink."""
|
||||
safe_title = self.safe_title
|
||||
if self.content_type == "text/markdown":
|
||||
|
||||
@@ -124,6 +124,7 @@ class EntitySummary(BaseModel):
|
||||
"""Simplified entity representation."""
|
||||
|
||||
type: Literal["entity"] = "entity"
|
||||
entity_id: int # Database ID for v2 API consistency
|
||||
permalink: Optional[str]
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
@@ -141,12 +142,16 @@ class RelationSummary(BaseModel):
|
||||
"""Simplified relation representation."""
|
||||
|
||||
type: Literal["relation"] = "relation"
|
||||
relation_id: int # Database ID for v2 API consistency
|
||||
entity_id: Optional[int] = None # ID of the entity this relation belongs to
|
||||
title: str
|
||||
file_path: str
|
||||
permalink: str
|
||||
relation_type: str
|
||||
from_entity: Optional[str] = None
|
||||
from_entity_id: Optional[int] = None # ID of source entity
|
||||
to_entity: Optional[str] = None
|
||||
to_entity_id: Optional[int] = None # ID of target entity
|
||||
created_at: Annotated[
|
||||
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
|
||||
]
|
||||
@@ -160,6 +165,8 @@ class ObservationSummary(BaseModel):
|
||||
"""Simplified observation representation."""
|
||||
|
||||
type: Literal["observation"] = "observation"
|
||||
observation_id: int # Database ID for v2 API consistency
|
||||
entity_id: Optional[int] = None # ID of the entity this observation belongs to
|
||||
title: str
|
||||
file_path: str
|
||||
permalink: str
|
||||
|
||||
@@ -173,6 +173,7 @@ class ProjectWatchStatus(BaseModel):
|
||||
class ProjectItem(BaseModel):
|
||||
"""Simple representation of a project."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
path: str
|
||||
is_default: bool = False
|
||||
|
||||
@@ -97,6 +97,11 @@ class SearchResult(BaseModel):
|
||||
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# IDs for v2 API consistency
|
||||
entity_id: Optional[int] = None # Entity ID (always present for entities)
|
||||
observation_id: Optional[int] = None # Observation ID (for observation results)
|
||||
relation_id: Optional[int] = None # Relation ID (for relation results)
|
||||
|
||||
# Type-specific fields
|
||||
category: Optional[str] = None # For observations
|
||||
from_entity: Optional[Permalink] = None # For relations
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""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,
|
||||
UpdateResourceRequest,
|
||||
ResourceResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EntityResolveRequest",
|
||||
"EntityResolveResponse",
|
||||
"EntityResponseV2",
|
||||
"MoveEntityRequestV2",
|
||||
"ProjectResolveRequest",
|
||||
"ProjectResolveResponse",
|
||||
"CreateResourceRequest",
|
||||
"UpdateResourceRequest",
|
||||
"ResourceResponse",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""V2 entity and project schemas with ID-first design."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from basic_memory.schemas.response import ObservationResponse, RelationResponse
|
||||
|
||||
|
||||
class EntityResolveRequest(BaseModel):
|
||||
"""Request to resolve a string identifier to an entity ID.
|
||||
|
||||
Supports resolution of:
|
||||
- Permalinks (e.g., "specs/search")
|
||||
- Titles (e.g., "Search Specification")
|
||||
- File paths (e.g., "specs/search.md")
|
||||
"""
|
||||
|
||||
identifier: str = Field(
|
||||
...,
|
||||
description="Entity identifier to resolve (permalink, title, or file path)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class EntityResolveResponse(BaseModel):
|
||||
"""Response from identifier resolution.
|
||||
|
||||
Returns the entity ID and associated metadata for the resolved entity.
|
||||
"""
|
||||
|
||||
entity_id: int = Field(..., description="Numeric entity ID (primary 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(
|
||||
..., description="How the identifier was resolved"
|
||||
)
|
||||
|
||||
|
||||
class MoveEntityRequestV2(BaseModel):
|
||||
"""V2 request schema for moving an entity to a new file location.
|
||||
|
||||
In V2 API, the entity ID is provided in the URL path, so this request
|
||||
only needs the destination path.
|
||||
"""
|
||||
|
||||
destination_path: str = Field(
|
||||
...,
|
||||
description="New file path for the entity (relative to project root)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class EntityResponseV2(BaseModel):
|
||||
"""V2 entity response with ID as the primary field.
|
||||
|
||||
This response format emphasizes the entity ID as the primary identifier,
|
||||
with all other fields (permalink, file_path) as secondary metadata.
|
||||
"""
|
||||
|
||||
# ID first - this is the primary identifier in v2
|
||||
id: int = Field(..., description="Numeric entity ID (primary identifier)")
|
||||
|
||||
# Core entity fields
|
||||
title: str = Field(..., description="Entity title")
|
||||
entity_type: str = Field(..., description="Entity type")
|
||||
content_type: str = Field(default="text/markdown", description="Content MIME type")
|
||||
|
||||
# Secondary identifiers (for compatibility and convenience)
|
||||
permalink: Optional[str] = Field(None, description="Entity permalink (may change)")
|
||||
file_path: str = Field(..., description="Relative file path (may change)")
|
||||
|
||||
# Content and metadata
|
||||
content: Optional[str] = Field(None, description="Entity content")
|
||||
entity_metadata: Optional[Dict] = Field(None, description="Entity metadata")
|
||||
|
||||
# Relationships
|
||||
observations: List[ObservationResponse] = Field(
|
||||
default_factory=list, description="Entity observations"
|
||||
)
|
||||
relations: List[RelationResponse] = Field(default_factory=list, description="Entity relations")
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
# V2-specific metadata
|
||||
api_version: Literal["v2"] = Field(
|
||||
default="v2", description="API version (always 'v2' for this response)"
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
project_id: int = Field(..., description="Numeric project ID (primary 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["id", "name", "permalink"] = Field(
|
||||
..., description="How the identifier was resolved"
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""V2 resource schemas for file content operations."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateResourceRequest(BaseModel):
|
||||
"""Request to create a new resource file.
|
||||
|
||||
File path is required for new resources since we need to know where
|
||||
to create the file.
|
||||
"""
|
||||
|
||||
file_path: str = Field(
|
||||
...,
|
||||
description="Path to create the file, relative to project root",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
content: str = Field(..., description="File content to write")
|
||||
|
||||
|
||||
class UpdateResourceRequest(BaseModel):
|
||||
"""Request to update an existing resource by entity ID.
|
||||
|
||||
Only content is required - the file path is already known from the entity.
|
||||
Optionally can update the file_path to move the file.
|
||||
"""
|
||||
|
||||
content: str = Field(..., description="File content to write")
|
||||
file_path: str | None = Field(
|
||||
None,
|
||||
description="Optional new file path to move the resource",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class ResourceResponse(BaseModel):
|
||||
"""Response from resource operations."""
|
||||
|
||||
entity_id: int = Field(..., description="Entity ID of the resource")
|
||||
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")
|
||||
created_at: float = Field(..., description="Creation timestamp")
|
||||
modified_at: float = Field(..., description="Modification timestamp")
|
||||
@@ -4,11 +4,13 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
||||
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
@@ -252,9 +254,6 @@ class ContextService:
|
||||
# Build the VALUES clause for entity IDs
|
||||
entity_id_values = ", ".join([str(i) for i in entity_ids])
|
||||
|
||||
# For compatibility with the old query, we still need this for filtering
|
||||
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
||||
|
||||
# Parameters for bindings - include project_id for security filtering
|
||||
params = {
|
||||
"max_depth": max_depth,
|
||||
@@ -264,7 +263,14 @@ class ContextService:
|
||||
|
||||
# Build date and timeframe filters conditionally based on since parameter
|
||||
if since:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
# SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
|
||||
if isinstance(self.search_repository, PostgresSearchRepository):
|
||||
# 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
|
||||
else:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
date_filter = "AND e.created_at >= :since_date"
|
||||
relation_date_filter = "AND e_from.created_at >= :since_date"
|
||||
timeframe_condition = "AND eg.relation_date >= :since_date"
|
||||
@@ -279,13 +285,210 @@ class ContextService:
|
||||
|
||||
# Use a CTE that operates directly on entity and relation tables
|
||||
# This avoids the overhead of the search_index virtual table
|
||||
query = text(f"""
|
||||
# Note: Postgres and SQLite have different CTE limitations:
|
||||
# - Postgres: doesn't allow multiple UNION ALL branches referencing the CTE
|
||||
# - SQLite: doesn't support LATERAL joins
|
||||
# So we need different queries for each database backend
|
||||
|
||||
# Detect database backend
|
||||
is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
|
||||
|
||||
if is_postgres:
|
||||
query = self._build_postgres_query(
|
||||
entity_id_values,
|
||||
date_filter,
|
||||
project_filter,
|
||||
relation_date_filter,
|
||||
relation_project_filter,
|
||||
timeframe_condition,
|
||||
)
|
||||
else:
|
||||
# SQLite needs VALUES clause for exclusion (not needed for Postgres)
|
||||
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
||||
query = self._build_sqlite_query(
|
||||
entity_id_values,
|
||||
date_filter,
|
||||
project_filter,
|
||||
relation_date_filter,
|
||||
relation_project_filter,
|
||||
timeframe_condition,
|
||||
values,
|
||||
)
|
||||
|
||||
result = await self.search_repository.execute_query(query, params=params)
|
||||
rows = result.all()
|
||||
|
||||
context_rows = [
|
||||
ContextResultRow(
|
||||
type=row.type,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
content=row.content,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return context_rows
|
||||
|
||||
def _build_postgres_query(
|
||||
self,
|
||||
entity_id_values: str,
|
||||
date_filter: str,
|
||||
project_filter: str,
|
||||
relation_date_filter: str,
|
||||
relation_project_filter: str,
|
||||
timeframe_condition: str,
|
||||
):
|
||||
"""Build Postgres-specific CTE query using LATERAL joins."""
|
||||
return text(f"""
|
||||
WITH RECURSIVE entity_graph AS (
|
||||
-- Base case: seed entities
|
||||
SELECT
|
||||
SELECT
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
e.title,
|
||||
e.permalink,
|
||||
e.file_path,
|
||||
CAST(NULL AS INTEGER) as from_id,
|
||||
CAST(NULL AS INTEGER) as to_id,
|
||||
CAST(NULL AS TEXT) as relation_type,
|
||||
CAST(NULL AS TEXT) as content,
|
||||
CAST(NULL AS TEXT) as category,
|
||||
CAST(NULL AS INTEGER) as entity_id,
|
||||
0 as depth,
|
||||
e.id as root_id,
|
||||
e.created_at,
|
||||
e.created_at as relation_date
|
||||
FROM entity e
|
||||
WHERE e.id IN ({entity_id_values})
|
||||
{date_filter}
|
||||
{project_filter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Fetch BOTH relations AND connected entities in a single recursive step
|
||||
-- Postgres only allows ONE reference to the recursive CTE in the recursive term
|
||||
-- We use CROSS JOIN LATERAL to generate two rows (relation + entity) from each traversal
|
||||
SELECT
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.id
|
||||
ELSE e.id
|
||||
END as id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN 'relation'
|
||||
ELSE 'entity'
|
||||
END as type,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.relation_type || ': ' || r.to_name
|
||||
ELSE e.title
|
||||
END as title,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN ''
|
||||
ELSE COALESCE(e.permalink, '')
|
||||
END as permalink,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.file_path
|
||||
ELSE e.file_path
|
||||
END as file_path,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.from_id
|
||||
ELSE NULL
|
||||
END as from_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.to_id
|
||||
ELSE NULL
|
||||
END as to_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.relation_type
|
||||
ELSE NULL
|
||||
END as relation_type,
|
||||
CAST(NULL AS TEXT) as content,
|
||||
CAST(NULL AS TEXT) as category,
|
||||
CAST(NULL AS INTEGER) as entity_id,
|
||||
eg.depth + step_type as depth,
|
||||
eg.root_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.created_at
|
||||
ELSE e.created_at
|
||||
END as created_at,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.created_at
|
||||
ELSE eg.relation_date
|
||||
END as relation_date
|
||||
FROM entity_graph eg
|
||||
CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
|
||||
JOIN relation r ON (
|
||||
eg.type = 'entity' AND
|
||||
(r.from_id = eg.id OR r.to_id = eg.id)
|
||||
)
|
||||
JOIN entity e_from ON (
|
||||
r.from_id = e_from.id
|
||||
{relation_project_filter}
|
||||
)
|
||||
LEFT JOIN entity e ON (
|
||||
step_type = 2 AND
|
||||
e.id = CASE
|
||||
WHEN r.from_id = eg.id THEN r.to_id
|
||||
ELSE r.from_id
|
||||
END
|
||||
{date_filter}
|
||||
{project_filter}
|
||||
)
|
||||
WHERE eg.depth < :max_depth
|
||||
AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
|
||||
{timeframe_condition}
|
||||
)
|
||||
-- Materialize and filter
|
||||
SELECT DISTINCT
|
||||
type,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
content,
|
||||
category,
|
||||
entity_id,
|
||||
MIN(depth) as depth,
|
||||
root_id,
|
||||
created_at
|
||||
FROM entity_graph
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
|
||||
def _build_sqlite_query(
|
||||
self,
|
||||
entity_id_values: str,
|
||||
date_filter: str,
|
||||
project_filter: str,
|
||||
relation_date_filter: str,
|
||||
relation_project_filter: str,
|
||||
timeframe_condition: str,
|
||||
values: str,
|
||||
):
|
||||
"""Build SQLite-specific CTE query using multiple UNION ALL branches."""
|
||||
return text(f"""
|
||||
WITH RECURSIVE entity_graph AS (
|
||||
-- Base case: seed entities
|
||||
SELECT
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
e.permalink,
|
||||
e.file_path,
|
||||
NULL as from_id,
|
||||
@@ -311,7 +514,6 @@ class ContextService:
|
||||
r.id,
|
||||
'relation' as type,
|
||||
r.relation_type || ': ' || r.to_name as title,
|
||||
-- Relation model doesn't have permalink column - we'll generate it at runtime
|
||||
'' as permalink,
|
||||
e_from.file_path,
|
||||
r.from_id,
|
||||
@@ -322,7 +524,7 @@ class ContextService:
|
||||
NULL as entity_id,
|
||||
eg.depth + 1,
|
||||
eg.root_id,
|
||||
e_from.created_at, -- Use the from_entity's created_at since relation has no timestamp
|
||||
e_from.created_at,
|
||||
e_from.created_at as relation_date,
|
||||
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
|
||||
FROM entity_graph eg
|
||||
@@ -337,7 +539,6 @@ class ContextService:
|
||||
)
|
||||
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
|
||||
WHERE eg.depth < :max_depth
|
||||
-- Ensure to_entity (if exists) also belongs to same project
|
||||
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
|
||||
|
||||
UNION ALL
|
||||
@@ -347,9 +548,9 @@ class ContextService:
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
CASE
|
||||
WHEN e.permalink IS NULL THEN ''
|
||||
ELSE e.permalink
|
||||
CASE
|
||||
WHEN e.permalink IS NULL THEN ''
|
||||
ELSE e.permalink
|
||||
END as permalink,
|
||||
e.file_path,
|
||||
NULL as from_id,
|
||||
@@ -366,7 +567,7 @@ class ContextService:
|
||||
FROM entity_graph eg
|
||||
JOIN entity e ON (
|
||||
eg.type = 'relation' AND
|
||||
e.id = CASE
|
||||
e.id = CASE
|
||||
WHEN eg.is_incoming = 0 THEN eg.to_id
|
||||
ELSE eg.from_id
|
||||
END
|
||||
@@ -374,10 +575,9 @@ class ContextService:
|
||||
{project_filter}
|
||||
)
|
||||
WHERE eg.depth < :max_depth
|
||||
-- Only include entities connected by relations within timeframe if specified
|
||||
{timeframe_condition}
|
||||
)
|
||||
SELECT DISTINCT
|
||||
SELECT DISTINCT
|
||||
type,
|
||||
id,
|
||||
title,
|
||||
@@ -393,33 +593,9 @@ class ContextService:
|
||||
root_id,
|
||||
created_at
|
||||
FROM entity_graph
|
||||
WHERE (type, id) NOT IN ({values})
|
||||
GROUP BY
|
||||
type, id
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
|
||||
result = await self.search_repository.execute_query(query, params=params)
|
||||
rows = result.all()
|
||||
|
||||
context_rows = [
|
||||
ContextResultRow(
|
||||
type=row.type,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
content=row.content,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return context_rows
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user