mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
27 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 |
@@ -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.
|
||||
+105
@@ -1,5 +1,110 @@
|
||||
# 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
|
||||
|
||||
@@ -58,11 +58,69 @@ See the [README.md](README.md) file for a project overview.
|
||||
- Follow the repository pattern for data access
|
||||
- Tools communicate to api routers via the httpx ASGI client (in process)
|
||||
|
||||
### Code Change Guidelines
|
||||
|
||||
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
|
||||
- **Minimize diffs**: Prefer the smallest change that satisfies the request. Avoid unrelated refactors or style rewrites unless necessary for correctness
|
||||
- **No speculative getattr**: Never use `getattr(obj, "attr", default)` when unsure about attribute names. Check the class definition or source code first
|
||||
- **Fail fast**: Write code with fail-fast logic by default. Do not swallow exceptions with errors or warnings
|
||||
- **No fallback logic**: Do not add fallback logic unless explicitly told to and agreed with the user
|
||||
- **No guessing**: Do not say "The issue is..." before you actually know what the issue is. Investigate first.
|
||||
|
||||
### Literate Programming Style
|
||||
|
||||
Code should tell a story. Comments must explain the "why" and narrative flow, not just the "what".
|
||||
|
||||
**Section Headers:**
|
||||
For files with multiple phases of logic, add section headers so the control flow reads like chapters:
|
||||
```python
|
||||
# --- Authentication ---
|
||||
# ... auth logic ...
|
||||
|
||||
# --- Data Validation ---
|
||||
# ... validation logic ...
|
||||
|
||||
# --- Business Logic ---
|
||||
# ... core logic ...
|
||||
```
|
||||
|
||||
**Decision Point Comments:**
|
||||
For conditionals that materially change behavior (gates, fallbacks, retries, feature flags), add comments with:
|
||||
- **Trigger**: what condition causes this branch
|
||||
- **Why**: the rationale (cost, correctness, UX, determinism)
|
||||
- **Outcome**: what changes downstream
|
||||
|
||||
```python
|
||||
# Trigger: project has no active sync watcher
|
||||
# Why: avoid duplicate file system watchers consuming resources
|
||||
# Outcome: starts new watcher, registers in active_watchers dict
|
||||
if project_id not in active_watchers:
|
||||
start_watcher(project_id)
|
||||
```
|
||||
|
||||
**Constraint Comments:**
|
||||
If code exists because of a constraint (async requirements, rate limits, schema compatibility), explain the constraint near the code:
|
||||
```python
|
||||
# SQLite requires WAL mode for concurrent read/write access
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
```
|
||||
|
||||
**What NOT to Comment:**
|
||||
Avoid comments that restate obvious code:
|
||||
```python
|
||||
# Bad - restates code
|
||||
counter += 1 # increment counter
|
||||
|
||||
# Good - explains why
|
||||
counter += 1 # track retries for backoff calculation
|
||||
```
|
||||
|
||||
### Codebase Architecture
|
||||
|
||||
- `/alembic` - Alembic db migrations
|
||||
- `/api` - FastAPI implementation of REST endpoints
|
||||
- `/cli` - Typer command-line interface
|
||||
- `/importers` - Import functionality for Claude, ChatGPT, and other sources
|
||||
- `/markdown` - Markdown parsing and processing
|
||||
- `/mcp` - Model Context Protocol server implementation
|
||||
- `/models` - SQLAlchemy ORM models
|
||||
@@ -140,22 +198,26 @@ See SPEC-16 for full context manager refactor details.
|
||||
### Basic Memory Commands
|
||||
|
||||
**Local Commands:**
|
||||
- Sync knowledge: `basic-memory sync` or `basic-memory sync --watch`
|
||||
- Check sync status: `basic-memory status`
|
||||
- Import from Claude: `basic-memory import claude conversations`
|
||||
- Import from ChatGPT: `basic-memory import chatgpt`
|
||||
- Import from Memory JSON: `basic-memory import memory-json`
|
||||
- Check sync status: `basic-memory status`
|
||||
- Tool access: `basic-memory tools` (provides CLI access to MCP tools)
|
||||
- Guide: `basic-memory tools basic-memory-guide`
|
||||
- Continue: `basic-memory tools continue-conversation --topic="search"`
|
||||
- Tool access: `basic-memory tool` (provides CLI access to MCP tools)
|
||||
- Continue: `basic-memory tool continue-conversation --topic="search"`
|
||||
|
||||
**Project Management:**
|
||||
- List projects: `basic-memory project list`
|
||||
- Add project: `basic-memory project add "name" ~/path`
|
||||
- Project info: `basic-memory project info`
|
||||
- One-way sync (local -> cloud): `basic-memory project sync`
|
||||
- Bidirectional sync: `basic-memory project bisync`
|
||||
- Integrity check: `basic-memory project check`
|
||||
|
||||
**Cloud Commands (requires subscription):**
|
||||
- Authenticate: `basic-memory cloud login`
|
||||
- Logout: `basic-memory cloud logout`
|
||||
- Bidirectional sync: `basic-memory cloud sync`
|
||||
- Integrity check: `basic-memory cloud check`
|
||||
- Mount cloud storage: `basic-memory cloud mount`
|
||||
- Unmount cloud storage: `basic-memory cloud unmount`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
|
||||
### MCP Capabilities
|
||||
|
||||
@@ -182,18 +244,19 @@ See SPEC-16 for full context manager refactor details.
|
||||
- `list_memory_projects()` - List all available projects with their status
|
||||
- `create_memory_project(project_name, project_path, set_default)` - Create new Basic Memory projects
|
||||
- `delete_project(project_name)` - Delete a project from configuration
|
||||
- `get_current_project()` - Get current project information and stats
|
||||
- `sync_status()` - Check file synchronization and background operation status
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
|
||||
**ChatGPT-Compatible Tools:**
|
||||
- `search(query)` - Search across knowledge base (OpenAI actions compatible)
|
||||
- `fetch(id)` - Fetch full content of a search result document
|
||||
|
||||
- MCP Prompts for better AI interaction:
|
||||
- `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants
|
||||
- `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context
|
||||
- `search(query, after_date)` - Search with detailed, formatted results for better context understanding
|
||||
- `recent_activity(timeframe)` - View recently changed items with formatted output
|
||||
- `json_canvas_spec()` - Full JSON Canvas specification for Obsidian visualization
|
||||
|
||||
### Cloud Features (v0.15.0+)
|
||||
|
||||
|
||||
@@ -466,6 +466,39 @@ tail -f ~/.basic-memory/basic-memory.log
|
||||
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
|
||||
|
||||
**What we collect:**
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands are used)
|
||||
- Error types (sanitized - no file paths or personal data)
|
||||
|
||||
**What we NEVER collect:**
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses
|
||||
|
||||
**Opting out:**
|
||||
```bash
|
||||
# Disable telemetry
|
||||
basic-memory telemetry disable
|
||||
|
||||
# Check status
|
||||
basic-memory telemetry status
|
||||
|
||||
# Re-enable
|
||||
basic-memory telemetry enable
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
```bash
|
||||
export BASIC_MEMORY_TELEMETRY_ENABLED=false
|
||||
```
|
||||
|
||||
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -37,11 +37,11 @@ test-postgres: test-unit-postgres test-int-postgres
|
||||
|
||||
# Run unit tests against SQLite
|
||||
test-unit-sqlite:
|
||||
uv run pytest -p pytest_mock -v --no-cov tests
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run unit tests against Postgres
|
||||
test-unit-postgres:
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests against SQLite
|
||||
test-int-sqlite:
|
||||
@@ -51,7 +51,16 @@ test-int-sqlite:
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
# See: https://github.com/jlowin/fastmcp/issues/1311
|
||||
test-int-postgres:
|
||||
timeout --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
|
||||
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
|
||||
if [[ -n "$TIMEOUT_CMD" ]]; then
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
fi
|
||||
|
||||
# Reset Postgres test database (drops and recreates schema)
|
||||
# Useful when Alembic migration state gets out of sync during development
|
||||
@@ -173,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..."
|
||||
|
||||
@@ -38,6 +38,10 @@ dependencies = [
|
||||
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"psycopg==3.3.1",
|
||||
"mdformat>=0.7.22",
|
||||
"mdformat-gfm>=0.3.7",
|
||||
"mdformat-frontmatter>=2.0.8",
|
||||
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.16.3"
|
||||
__version__ = "0.17.2"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -21,8 +21,12 @@ from alembic import context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
# Trigger: only set test env when actually running under pytest
|
||||
# Why: alembic/env.py is imported during normal operations (MCP server startup, migrations)
|
||||
# but we only want test behavior during actual test runs
|
||||
# Outcome: prevents is_test_env from returning True in production, enabling watch service
|
||||
if os.getenv("PYTEST_CURRENT_TEST") is not None:
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
|
||||
# Import after setting environment variable # noqa: E402
|
||||
from basic_memory.models import Base # noqa: E402
|
||||
|
||||
+112
-72
@@ -10,6 +10,42 @@ from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
@@ -36,101 +72,105 @@ def upgrade() -> None:
|
||||
# Add project_id to relation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first
|
||||
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "relation", "project_id"):
|
||||
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via from_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE relation.from_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
# Step 2: Backfill project_id from entity.project_id via from_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = relation.from_id
|
||||
)
|
||||
""")
|
||||
WHERE relation.from_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = relation.from_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("relation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"relation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("relation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"relation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on relation.project_id
|
||||
op.create_index("ix_relation_project_id", "relation", ["project_id"])
|
||||
# Step 4: Create index on relation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_relation_project_id"):
|
||||
op.create_index("ix_relation_project_id", "relation", ["project_id"])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add project_id to observation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first
|
||||
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "observation", "project_id"):
|
||||
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via entity_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE observation.entity_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
# Step 2: Backfill project_id from entity.project_id via entity_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = observation.entity_id
|
||||
)
|
||||
""")
|
||||
WHERE observation.entity_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = observation.entity_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("observation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"observation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("observation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"observation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on observation.project_id
|
||||
op.create_index("ix_observation_project_id", "observation", ["project_id"])
|
||||
# Step 4: Create index on observation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_observation_project_id"):
|
||||
op.create_index("ix_observation_project_id", "observation", ["project_id"])
|
||||
|
||||
# Postgres-specific: pg_trgm and GIN indexes
|
||||
if dialect == "postgresql":
|
||||
|
||||
@@ -53,12 +53,20 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
app.state.session_maker = session_maker
|
||||
logger.info("Database connections cached in app state")
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# Start file sync if enabled
|
||||
if app_config.sync_changes and not app_config.is_test_env:
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
|
||||
# start file sync task in background
|
||||
app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
|
||||
async def _file_sync_runner() -> None:
|
||||
await initialize_file_sync(app_config)
|
||||
|
||||
app.state.sync_task = asyncio.create_task(_file_sync_runner())
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected. Skipping file sync service.")
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
app.state.sync_task = None
|
||||
|
||||
# proceed with startup
|
||||
|
||||
@@ -274,7 +274,7 @@ async def add_project(
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
@@ -329,7 +329,7 @@ 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(
|
||||
|
||||
@@ -96,9 +96,7 @@ async def resolve_identifier(
|
||||
# Try to resolve the identifier
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Could not resolve identifier: '{data.identifier}'"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
||||
|
||||
# Determine resolution method
|
||||
resolution_method = "search" # default
|
||||
|
||||
@@ -25,11 +25,89 @@ from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.utils import normalize_project_path
|
||||
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
|
||||
from basic_memory.utils import normalize_project_path, generate_permalink
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
||||
|
||||
|
||||
@router.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,
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
from typing import Optional
|
||||
# Suppress Logfire "not configured" warning - we only use Logfire in cloud/server contexts
|
||||
import os
|
||||
|
||||
import typer
|
||||
os.environ.setdefault("LOGFIRE_IGNORE_NO_CONFIG", "1")
|
||||
|
||||
from basic_memory.config import ConfigManager, init_cli_logging
|
||||
# Remove loguru's default handler IMMEDIATELY, before any other imports.
|
||||
# This prevents DEBUG logs from appearing on stdout during module-level
|
||||
# initialization (e.g., template_loader.TemplateLoader() logs at DEBUG level).
|
||||
from loguru import logger
|
||||
|
||||
logger.remove()
|
||||
|
||||
from typing import Optional # noqa: E402
|
||||
|
||||
import typer # noqa: E402
|
||||
|
||||
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -34,8 +47,22 @@ def app_callback(
|
||||
# Initialize logging for CLI (file only, no stdout)
|
||||
init_cli_logging()
|
||||
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
# Show telemetry notice and track CLI startup
|
||||
# Skip for 'mcp' command - it handles its own telemetry in lifespan
|
||||
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
|
||||
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
|
||||
show_notice_if_needed()
|
||||
track_app_started("cli")
|
||||
|
||||
# Run initialization for commands that don't use the API
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
api_commands = {"mcp", "status", "sync", "project", "tool"}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
and ctx.invoked_subcommand not in api_commands
|
||||
):
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
@@ -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,11 +9,14 @@ This module provides simplified, project-scoped rclone operations:
|
||||
Replaces tenant-wide sync with project-scoped workflows.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
||||
@@ -21,6 +24,9 @@ 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."""
|
||||
@@ -43,6 +49,42 @@ def check_rclone_installed() -> None:
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
@@ -218,7 +260,6 @@ def project_bisync(
|
||||
"bisync",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
"--create-empty-src-dirs",
|
||||
"--resilient",
|
||||
"--conflict-resolve=newer",
|
||||
"--max-delete=25",
|
||||
@@ -229,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:
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
"""MCP server command with streamable HTTP transport."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import typer
|
||||
from typing import Optional
|
||||
@@ -8,7 +7,7 @@ from typing import Optional
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, init_mcp_logging
|
||||
|
||||
# Import mcp instance
|
||||
# Import mcp instance (has lifespan that handles initialization and file sync)
|
||||
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
|
||||
# Import mcp tools to register them
|
||||
@@ -17,8 +16,6 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
from loguru import logger
|
||||
import threading
|
||||
from basic_memory.services.initialization import initialize_file_sync
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
@@ -43,6 +40,8 @@ if not config.cloud_mode_enabled:
|
||||
- stdio: Standard I/O (good for local usage)
|
||||
- streamable-http: Recommended for web deployments (default)
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
|
||||
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
|
||||
"""
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
@@ -59,27 +58,8 @@ if not config.cloud_mode_enabled:
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"MCP server constrained to project: {project_name}")
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
def run_file_sync():
|
||||
"""Run file sync in a separate thread with its own event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(initialize_file_sync(app_config))
|
||||
except Exception as e:
|
||||
logger.error(f"File sync error: {e}", err=True)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# Start the sync thread
|
||||
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
|
||||
sync_thread.start()
|
||||
logger.info("Started file sync in background")
|
||||
|
||||
# Now run the MCP server (blocks)
|
||||
# Run the MCP server (blocks)
|
||||
# Lifespan handles: initialization, migrations, file sync, cleanup
|
||||
logger.info(f"Starting MCP server with {transport.upper()} transport")
|
||||
|
||||
if transport == "stdio":
|
||||
|
||||
@@ -16,14 +16,9 @@ from datetime import datetime
|
||||
|
||||
from rich.panel import Panel
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.project_info import ProjectStatusResponse
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
from basic_memory.mcp.tools.utils import call_patch
|
||||
|
||||
# Import rclone commands for project sync
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
@@ -254,9 +249,17 @@ def remove_project(
|
||||
|
||||
async def _remove_project():
|
||||
async with get_client() as client:
|
||||
# Convert name to permalink for efficient resolution
|
||||
project_permalink = generate_permalink(name)
|
||||
|
||||
# Use v2 project resolver to find project ID by permalink
|
||||
resolve_data = {"identifier": project_permalink}
|
||||
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
|
||||
target_project = response.json()
|
||||
|
||||
# Use v2 API with project ID
|
||||
response = await call_delete(
|
||||
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
|
||||
client, f"/v2/projects/{target_project['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()
|
||||
|
||||
@@ -165,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
||||
)
|
||||
|
||||
# File formatting configuration
|
||||
format_on_save: bool = Field(
|
||||
default=False,
|
||||
description="Automatically format files after saving using configured formatter. Disabled by default.",
|
||||
)
|
||||
|
||||
formatter_command: Optional[str] = Field(
|
||||
default=None,
|
||||
description="External formatter command. Use {file} as placeholder for file path. If not set, uses built-in mdformat (Python, no Node.js required). Set to 'npx prettier --write {file}' for Prettier.",
|
||||
)
|
||||
|
||||
formatters: Dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
|
||||
)
|
||||
|
||||
formatter_timeout: float = Field(
|
||||
default=5.0,
|
||||
description="Maximum seconds to wait for formatter to complete",
|
||||
gt=0,
|
||||
)
|
||||
|
||||
# Project path constraints
|
||||
project_root: Optional[str] = Field(
|
||||
default=None,
|
||||
@@ -199,6 +221,34 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
# Telemetry configuration (Homebrew-style opt-out)
|
||||
telemetry_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
|
||||
)
|
||||
|
||||
telemetry_notice_shown: bool = Field(
|
||||
default=False,
|
||||
description="Whether the one-time telemetry notice has been shown to the user",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_test_env(self) -> bool:
|
||||
"""Check if running in a test environment.
|
||||
|
||||
Returns True if any of:
|
||||
- env field is set to "test"
|
||||
- BASIC_MEMORY_ENV environment variable is "test"
|
||||
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
|
||||
|
||||
Used to disable features like telemetry and file watchers during tests.
|
||||
"""
|
||||
return (
|
||||
self.env == "test"
|
||||
or os.getenv("BASIC_MEMORY_ENV", "").lower() == "test"
|
||||
or os.getenv("PYTEST_CURRENT_TEST") is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def cloud_mode_enabled(self) -> bool:
|
||||
"""Check if cloud mode is enabled.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,21 @@ from sqlalchemy.pool import NullPool
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Windows event loop policy
|
||||
# -----------------------------------------------------------------------------
|
||||
# On Windows, the default ProactorEventLoop has known rough edges with aiosqlite
|
||||
# during shutdown/teardown (threads posting results to a loop that's closing),
|
||||
# which can manifest as:
|
||||
# - "RuntimeError: Event loop is closed"
|
||||
# - "IndexError: pop from an empty deque"
|
||||
#
|
||||
# The SelectorEventLoop doesn't support subprocess operations, so code that uses
|
||||
# asyncio.create_subprocess_shell() (like sync_service._quick_count_files) must
|
||||
# detect Windows and use fallback implementations.
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# Module level state
|
||||
_engine: Optional[AsyncEngine] = None
|
||||
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
|
||||
@@ -351,24 +351,30 @@ async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityPars
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_markdown_processor(
|
||||
entity_parser: EntityParserDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2(entity_parser: EntityParserV2Dep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_markdown_processor_v2(
|
||||
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
|
||||
)
|
||||
@@ -379,9 +385,11 @@ FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
@@ -398,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."""
|
||||
@@ -408,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,
|
||||
)
|
||||
|
||||
@@ -422,6 +432,7 @@ async def get_entity_service_v2(
|
||||
entity_parser: EntityParserV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
link_resolver: "LinkResolverV2Dep",
|
||||
search_service: "SearchServiceV2Dep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API."""
|
||||
@@ -432,6 +443,7 @@ async def get_entity_service_v2(
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Utilities for file operations."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Dict, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
|
||||
import aiofiles
|
||||
import yaml
|
||||
@@ -14,6 +16,9 @@ from loguru import logger
|
||||
|
||||
from basic_memory.utils import FilePath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
@@ -69,6 +74,28 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
|
||||
raise FileError(f"Failed to compute checksum: {e}")
|
||||
|
||||
|
||||
# UTF-8 BOM character that can appear at the start of files
|
||||
UTF8_BOM = "\ufeff"
|
||||
|
||||
|
||||
def strip_bom(content: str) -> str:
|
||||
"""Strip UTF-8 BOM from the start of content if present.
|
||||
|
||||
BOM (Byte Order Mark) characters can be present in files created on Windows
|
||||
or copied from certain sources. They should be stripped before processing
|
||||
frontmatter. See issue #452.
|
||||
|
||||
Args:
|
||||
content: Content that may start with BOM
|
||||
|
||||
Returns:
|
||||
Content with BOM removed if present
|
||||
"""
|
||||
if content and content.startswith(UTF8_BOM):
|
||||
return content[1:]
|
||||
return content
|
||||
|
||||
|
||||
async def write_file_atomic(path: FilePath, content: str) -> None:
|
||||
"""
|
||||
Write file with atomic operation using temporary file.
|
||||
@@ -100,6 +127,168 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
|
||||
raise FileWriteError(f"Failed to write file {path}: {e}")
|
||||
|
||||
|
||||
async def format_markdown_builtin(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Format a markdown file using the built-in mdformat formatter.
|
||||
|
||||
Uses mdformat with GFM (GitHub Flavored Markdown) support for consistent
|
||||
formatting without requiring Node.js or external tools.
|
||||
|
||||
Args:
|
||||
path: Path to the markdown file to format
|
||||
|
||||
Returns:
|
||||
Formatted content if successful, None if formatting failed.
|
||||
"""
|
||||
try:
|
||||
import mdformat
|
||||
except ImportError:
|
||||
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.
|
||||
@@ -113,7 +302,8 @@ def has_frontmatter(content: str) -> bool:
|
||||
if not content:
|
||||
return False
|
||||
|
||||
content = content.strip()
|
||||
# Strip BOM before checking for frontmatter markers
|
||||
content = strip_bom(content).strip()
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
|
||||
@@ -134,6 +324,8 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
|
||||
ParseError: If frontmatter is invalid or parsing fails
|
||||
"""
|
||||
try:
|
||||
# Strip BOM before parsing frontmatter
|
||||
content = strip_bom(content)
|
||||
if not content.strip().startswith("---"):
|
||||
raise ParseError("Content has no frontmatter")
|
||||
|
||||
@@ -175,7 +367,8 @@ def remove_frontmatter(content: str) -> str:
|
||||
Raises:
|
||||
ParseError: If content starts with frontmatter marker but is malformed
|
||||
"""
|
||||
content = content.strip()
|
||||
# Strip BOM before processing
|
||||
content = strip_bom(content).strip()
|
||||
|
||||
# Return as-is if no frontmatter marker
|
||||
if not content.startswith("---"):
|
||||
|
||||
@@ -5,15 +5,18 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def clean_filename(name: str) -> str: # pragma: no cover
|
||||
def clean_filename(name: str | None) -> str: # pragma: no cover
|
||||
"""Clean a string to be used as a filename.
|
||||
|
||||
Args:
|
||||
name: The string to clean.
|
||||
name: The string to clean (can be None).
|
||||
|
||||
Returns:
|
||||
A cleaned string suitable for use as a filename.
|
||||
"""
|
||||
# Handle None or empty input
|
||||
if not name:
|
||||
return "untitled"
|
||||
# Replace common punctuation and whitespace with underscores
|
||||
name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
|
||||
# Remove any non-alphanumeric or underscore characters
|
||||
|
||||
@@ -227,6 +227,12 @@ class EntityParser:
|
||||
Returns:
|
||||
EntityMarkdown with parsed content
|
||||
"""
|
||||
# Strip BOM before parsing (can be present in files from Windows or certain sources)
|
||||
# See issue #452
|
||||
from basic_memory.file_utils import strip_bom
|
||||
|
||||
content = strip_bom(content)
|
||||
|
||||
# Parse frontmatter with proper error handling for malformed YAML
|
||||
try:
|
||||
post = frontmatter.loads(content)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from collections import OrderedDict
|
||||
|
||||
from frontmatter import Post
|
||||
@@ -11,6 +11,9 @@ from basic_memory.file_utils import dump_frontmatter
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class DirtyFileError(Exception):
|
||||
"""Raised when attempting to write to a file that has been modified."""
|
||||
@@ -36,9 +39,14 @@ class MarkdownProcessor:
|
||||
3. Track schema changes (that's done by the database)
|
||||
"""
|
||||
|
||||
def __init__(self, entity_parser: EntityParser):
|
||||
"""Initialize processor with base path and parser."""
|
||||
def __init__(
|
||||
self,
|
||||
entity_parser: EntityParser,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
):
|
||||
"""Initialize processor with parser and optional config."""
|
||||
self.entity_parser = entity_parser
|
||||
self.app_config = app_config
|
||||
|
||||
async def read_file(self, path: Path) -> EntityMarkdown:
|
||||
"""Read and parse file into EntityMarkdown schema.
|
||||
@@ -123,7 +131,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.
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProjectRepository(Repository[Project]):
|
||||
super().__init__(session_maker, Project)
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[Project]:
|
||||
"""Get project by name.
|
||||
"""Get project by name (exact match).
|
||||
|
||||
Args:
|
||||
name: Unique name of the project
|
||||
@@ -32,6 +32,18 @@ class ProjectRepository(Repository[Project]):
|
||||
query = self.select().where(Project.name == name)
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_name_case_insensitive(self, name: str) -> Optional[Project]:
|
||||
"""Get project by name (case-insensitive match).
|
||||
|
||||
Args:
|
||||
name: Project name (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Project if found, None otherwise
|
||||
"""
|
||||
query = self.select().where(Project.name.ilike(name))
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_permalink(self, permalink: str) -> Optional[Project]:
|
||||
"""Get project by permalink.
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ ObservationStr = Annotated[
|
||||
str,
|
||||
BeforeValidator(str.strip), # Clean whitespace
|
||||
MinLen(1), # Ensure non-empty after stripping
|
||||
MaxLen(1000), # Keep reasonable length
|
||||
# No MaxLen - matches DB Text column which has no length restriction
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""V2 API schemas - ID-based entity references."""
|
||||
"""V2 API schemas - ID-based entity and project references."""
|
||||
|
||||
from basic_memory.schemas.v2.entity import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
ProjectResolveRequest,
|
||||
ProjectResolveResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
@@ -17,6 +19,8 @@ __all__ = [
|
||||
"EntityResolveResponse",
|
||||
"EntityResponseV2",
|
||||
"MoveEntityRequestV2",
|
||||
"ProjectResolveRequest",
|
||||
"ProjectResolveResponse",
|
||||
"CreateResourceRequest",
|
||||
"UpdateResourceRequest",
|
||||
"ResourceResponse",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""V2 entity schemas with ID-first design."""
|
||||
"""V2 entity and project schemas with ID-first design."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
@@ -94,3 +94,36 @@ class EntityResponseV2(BaseModel):
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProjectResolveRequest(BaseModel):
|
||||
"""Request to resolve a project identifier to a project ID.
|
||||
|
||||
Supports resolution of:
|
||||
- Project names (e.g., "my-project")
|
||||
- Permalinks (e.g., "my-project")
|
||||
"""
|
||||
|
||||
identifier: str = Field(
|
||||
...,
|
||||
description="Project identifier to resolve (name or permalink)",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
|
||||
class ProjectResolveResponse(BaseModel):
|
||||
"""Response from project identifier resolution.
|
||||
|
||||
Returns the project ID and associated metadata for the resolved project.
|
||||
"""
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.services import BaseService, FileService
|
||||
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -43,6 +44,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
relation_repository: RelationRepository,
|
||||
file_service: FileService,
|
||||
link_resolver: LinkResolver,
|
||||
search_service: Optional[SearchService] = None,
|
||||
app_config: Optional[BasicMemoryConfig] = None,
|
||||
):
|
||||
super().__init__(entity_repository)
|
||||
@@ -51,6 +53,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
self.entity_parser = entity_parser
|
||||
self.file_service = file_service
|
||||
self.link_resolver = link_resolver
|
||||
self.search_service = search_service
|
||||
self.app_config = app_config
|
||||
|
||||
async def detect_file_path_conflicts(
|
||||
@@ -354,7 +357,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
)
|
||||
entity = entities[0]
|
||||
|
||||
# Delete file first
|
||||
# Delete from search index first (if search_service is available)
|
||||
if self.search_service:
|
||||
await self.search_service.handle_delete(entity)
|
||||
|
||||
# Delete file
|
||||
await self.file_service.delete_entity_file(entity)
|
||||
|
||||
# Delete from DB (this will cascade to observations/relations)
|
||||
|
||||
@@ -5,13 +5,16 @@ import hashlib
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import aiofiles
|
||||
|
||||
import yaml
|
||||
|
||||
from basic_memory import file_utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.file_utils import FileError, FileMetadata, ParseError
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
@@ -42,9 +45,11 @@ class FileService:
|
||||
base_path: Path,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
max_concurrent_files: int = 10,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
):
|
||||
self.base_path = base_path.resolve() # Get absolute path
|
||||
self.markdown_processor = markdown_processor
|
||||
self.app_config = app_config
|
||||
# Semaphore to limit concurrent file operations
|
||||
# Prevents OOM on large projects by processing files in batches
|
||||
self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
|
||||
@@ -149,12 +154,15 @@ class FileService:
|
||||
Handles both absolute and relative paths. Relative paths are resolved
|
||||
against base_path.
|
||||
|
||||
If format_on_save is enabled in config, runs the configured formatter
|
||||
after writing and returns the checksum of the formatted content.
|
||||
|
||||
Args:
|
||||
path: Where to write (Path or string)
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
Checksum of written content
|
||||
Checksum of written content (or formatted content if formatting enabled)
|
||||
|
||||
Raises:
|
||||
FileOperationError: If write fails
|
||||
@@ -177,8 +185,17 @@ class FileService:
|
||||
|
||||
await file_utils.write_file_atomic(full_path, content)
|
||||
|
||||
# Compute and return checksum
|
||||
checksum = await file_utils.compute_checksum(content)
|
||||
# Format file if configured
|
||||
final_content = content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file(
|
||||
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
||||
)
|
||||
if formatted_content is not None:
|
||||
final_content = formatted_content
|
||||
|
||||
# Compute and return checksum of final content
|
||||
checksum = await file_utils.compute_checksum(final_content)
|
||||
logger.debug(f"File write completed path={full_path}, {checksum=}")
|
||||
return checksum
|
||||
|
||||
@@ -405,7 +422,17 @@ class FileService:
|
||||
)
|
||||
|
||||
await file_utils.write_file_atomic(full_path, final_content)
|
||||
return await file_utils.compute_checksum(final_content)
|
||||
|
||||
# Format file if configured
|
||||
content_for_checksum = final_content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file(
|
||||
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
||||
)
|
||||
if formatted_content is not None:
|
||||
content_for_checksum = formatted_content
|
||||
|
||||
return await file_utils.compute_checksum(content_for_checksum)
|
||||
|
||||
except Exception as e:
|
||||
# Only log real errors (not YAML parsing, which is handled above)
|
||||
|
||||
@@ -6,6 +6,7 @@ to ensure consistent application startup across all entry points.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -29,15 +30,12 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None:
|
||||
Database migrations are now handled automatically when the database
|
||||
connection is first established via get_or_create_db().
|
||||
"""
|
||||
# Trigger database initialization and migrations by getting the database connection
|
||||
try:
|
||||
await db.get_or_create_db(app_config.database_path)
|
||||
logger.info("Database initialization completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
# Allow application to continue - it might still work
|
||||
# depending on what the error was, and will fail with a
|
||||
# more specific error if the database is actually unusable
|
||||
logger.error(f"Error during database initialization: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
||||
@@ -51,31 +49,29 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
||||
"""
|
||||
logger.info("Reconciling projects from config with database...")
|
||||
|
||||
# Get database session - migrations handled centrally
|
||||
# Get database session (engine already created by initialize_database)
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
ensure_migrations=False,
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# Import ProjectService here to avoid circular imports
|
||||
from basic_memory.services.project_service import ProjectService
|
||||
|
||||
# Create project service and synchronize projects
|
||||
project_service = ProjectService(repository=project_repository)
|
||||
try:
|
||||
# Create project service and synchronize projects
|
||||
project_service = ProjectService(repository=project_repository)
|
||||
await project_service.synchronize_projects()
|
||||
logger.info("Projects successfully reconciled between config and database")
|
||||
except Exception as e:
|
||||
# Log the error but continue with initialization
|
||||
logger.error(f"Error during project synchronization: {e}")
|
||||
logger.info("Continuing with initialization despite synchronization error")
|
||||
|
||||
|
||||
async def initialize_file_sync(
|
||||
app_config: BasicMemoryConfig,
|
||||
):
|
||||
) -> None:
|
||||
"""Initialize file synchronization services. This function starts the watch service and does not return
|
||||
|
||||
Args:
|
||||
@@ -84,15 +80,20 @@ async def initialize_file_sync(
|
||||
Returns:
|
||||
The watch service task that's monitoring file changes
|
||||
"""
|
||||
# Never start file watching during tests. Even "background" watchers add tasks/threads
|
||||
# and can interact badly with strict asyncio teardown (especially on Windows/aiosqlite).
|
||||
# Skip file sync in test environments to avoid interference with tests
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected - skipping file sync initialization")
|
||||
return None
|
||||
|
||||
# delay import
|
||||
from basic_memory.sync import WatchService
|
||||
|
||||
# Load app configuration - migrations handled centrally
|
||||
# Get database session (migrations already run if needed)
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
ensure_migrations=False,
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
@@ -139,12 +140,10 @@ async def initialize_file_sync(
|
||||
|
||||
# Then start the watch service in the background
|
||||
logger.info("Starting watch service for all projects")
|
||||
|
||||
# run the watch service
|
||||
try:
|
||||
await watch_service.run()
|
||||
logger.info("Watch service started")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error starting watch service: {e}")
|
||||
await watch_service.run()
|
||||
logger.info("Watch service started")
|
||||
|
||||
return None
|
||||
|
||||
@@ -163,6 +162,11 @@ async def initialize_app(
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
"""
|
||||
# Skip initialization in cloud mode - cloud manages its own projects
|
||||
if app_config.cloud_mode_enabled:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
return
|
||||
|
||||
logger.info("Initializing app...")
|
||||
# Initialize database first
|
||||
await initialize_database(app_config)
|
||||
@@ -189,11 +193,24 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
return
|
||||
|
||||
try:
|
||||
result = asyncio.run(initialize_app(app_config))
|
||||
logger.info(f"Initialization completed successfully: result={result}")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception(f"Error during initialization: {e}")
|
||||
# Continue execution even if initialization fails
|
||||
# The command might still work, or will fail with a
|
||||
# more specific error message
|
||||
async def _init_and_cleanup():
|
||||
"""Initialize app and clean up database connections.
|
||||
|
||||
Database connections created during initialization must be cleaned up
|
||||
before the event loop closes, otherwise the process will hang indefinitely.
|
||||
"""
|
||||
try:
|
||||
await initialize_app(app_config)
|
||||
finally:
|
||||
# Always cleanup database connections to prevent process hang
|
||||
await db.shutdown_db()
|
||||
|
||||
# On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
|
||||
# The ProactorEventLoop can raise "IndexError: pop from an empty deque" during
|
||||
# event loop cleanup when there are pending handles. SelectorEventLoop is more
|
||||
# stable for our use case (no subprocess pipes or named pipes needed).
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
asyncio.run(_init_and_cleanup())
|
||||
logger.info("Initialization completed successfully")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
@@ -609,6 +610,16 @@ class SyncService:
|
||||
)
|
||||
return entity, checksum
|
||||
|
||||
except FileNotFoundError:
|
||||
# File exists in database but not on filesystem
|
||||
# This indicates a database/filesystem inconsistency - treat as deletion
|
||||
logger.warning(
|
||||
f"File not found during sync, treating as deletion: path={path}. "
|
||||
"This may indicate a race condition or manual file deletion."
|
||||
)
|
||||
await self.handle_delete(path)
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
# Check if this is a fatal error (or caused by one)
|
||||
# Fatal errors like project deletion should terminate sync immediately
|
||||
@@ -1027,12 +1038,22 @@ class SyncService:
|
||||
Uses subprocess to leverage OS-level file counting which is much faster
|
||||
than Python iteration, especially on network filesystems like TigrisFS.
|
||||
|
||||
On Windows, subprocess is not supported with SelectorEventLoop (which we use
|
||||
to avoid aiosqlite cleanup issues), so we fall back to Python-based counting.
|
||||
|
||||
Args:
|
||||
directory: Directory to count files in
|
||||
|
||||
Returns:
|
||||
Number of files in directory (recursive)
|
||||
"""
|
||||
# Windows with SelectorEventLoop doesn't support subprocess
|
||||
if sys.platform == "win32":
|
||||
count = 0
|
||||
async for _ in self.scan_directory(directory):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f'find "{directory}" -type f | wc -l',
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@@ -1063,6 +1084,9 @@ class SyncService:
|
||||
This is dramatically faster than scanning all files and comparing mtimes,
|
||||
especially on network filesystems like TigrisFS where stat operations are expensive.
|
||||
|
||||
On Windows, subprocess is not supported with SelectorEventLoop (which we use
|
||||
to avoid aiosqlite cleanup issues), so we implement mtime filtering in Python.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
since_timestamp: Unix timestamp to find files newer than
|
||||
@@ -1070,6 +1094,16 @@ class SyncService:
|
||||
Returns:
|
||||
List of relative file paths modified since the timestamp (respects .bmignore)
|
||||
"""
|
||||
# Windows with SelectorEventLoop doesn't support subprocess
|
||||
# Implement mtime filtering in Python to preserve watermark optimization
|
||||
if sys.platform == "win32":
|
||||
file_paths = []
|
||||
async for file_path_str, stat_info in self.scan_directory(directory):
|
||||
if stat_info.st_mtime > since_timestamp:
|
||||
rel_path = Path(file_path_str).relative_to(directory).as_posix()
|
||||
file_paths.append(rel_path)
|
||||
return file_paths
|
||||
|
||||
# Convert timestamp to find-compatible format
|
||||
since_date = datetime.fromtimestamp(since_timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -1186,8 +1220,8 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
||||
|
||||
project_path = Path(project.path)
|
||||
entity_parser = EntityParser(project_path)
|
||||
markdown_processor = MarkdownProcessor(entity_parser)
|
||||
file_service = FileService(project_path, markdown_processor)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(project_path, markdown_processor, app_config=app_config)
|
||||
|
||||
# Initialize repositories
|
||||
entity_repository = EntityRepository(session_maker, project_id=project.id)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
|
||||
|
||||
This module implements privacy-respecting usage analytics following the Homebrew model:
|
||||
- Telemetry is ON by default
|
||||
- Users can easily opt out: `bm telemetry disable`
|
||||
- First run shows a one-time notice (not a prompt)
|
||||
- Only anonymous data is collected (random UUID, no personal info)
|
||||
|
||||
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 (OpenPanel doesn't store these)
|
||||
|
||||
Documentation: https://basicmemory.com/telemetry
|
||||
"""
|
||||
|
||||
import platform
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from openpanel import OpenPanel
|
||||
|
||||
from basic_memory import __version__
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
# OpenPanel credentials (write-only, safe to embed in client code)
|
||||
# These can only send events to our dashboard, not read any data
|
||||
OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
|
||||
OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
|
||||
|
||||
TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
|
||||
|
||||
TELEMETRY_NOTICE = f"""
|
||||
Basic Memory collects anonymous usage statistics to help improve the software.
|
||||
This includes: version, OS, feature usage, and errors. No personal data or note content.
|
||||
|
||||
To opt out: bm telemetry disable
|
||||
Details: {TELEMETRY_DOCS_URL}
|
||||
"""
|
||||
|
||||
# --- Module State ---
|
||||
|
||||
_client: OpenPanel | None = None
|
||||
_initialized: bool = False
|
||||
|
||||
|
||||
# --- Installation ID ---
|
||||
|
||||
|
||||
def get_install_id() -> str:
|
||||
"""Get or create anonymous installation ID.
|
||||
|
||||
Creates a random UUID on first run and stores it locally.
|
||||
User can delete ~/.basic-memory/.install_id to reset.
|
||||
"""
|
||||
id_file = Path.home() / ".basic-memory" / ".install_id"
|
||||
|
||||
if id_file.exists():
|
||||
return id_file.read_text().strip()
|
||||
|
||||
install_id = str(uuid.uuid4())
|
||||
id_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
id_file.write_text(install_id)
|
||||
return install_id
|
||||
|
||||
|
||||
# --- Client Management ---
|
||||
|
||||
|
||||
def _get_client() -> OpenPanel:
|
||||
"""Get or create the OpenPanel client (singleton).
|
||||
|
||||
Lazily initializes the client with global properties.
|
||||
"""
|
||||
global _client, _initialized
|
||||
|
||||
if _client is None:
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
# Trigger: first call to track an event
|
||||
# Why: lazy init avoids work if telemetry never used; disabled flag
|
||||
# tells OpenPanel to skip network calls when user opts out or during tests
|
||||
# Outcome: client ready to queue events (or silently discard if disabled)
|
||||
is_disabled = not config.telemetry_enabled or config.is_test_env
|
||||
_client = OpenPanel(
|
||||
client_id=OPENPANEL_CLIENT_ID,
|
||||
client_secret=OPENPANEL_CLIENT_SECRET,
|
||||
disabled=is_disabled,
|
||||
)
|
||||
|
||||
if config.telemetry_enabled and not config.is_test_env and not _initialized:
|
||||
# Set global properties that go with every event
|
||||
_client.set_global_properties(
|
||||
{
|
||||
"app_version": __version__,
|
||||
"python_version": platform.python_version(),
|
||||
"os": platform.system().lower(),
|
||||
"arch": platform.machine(),
|
||||
"install_id": get_install_id(),
|
||||
"source": "foss",
|
||||
}
|
||||
)
|
||||
_initialized = True
|
||||
|
||||
return _client
|
||||
|
||||
|
||||
def reset_client() -> None:
|
||||
"""Reset the telemetry client (for testing or after config changes)."""
|
||||
global _client, _initialized
|
||||
_client = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
# --- Event Tracking ---
|
||||
|
||||
|
||||
def track(event: str, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Track an event. Fire-and-forget, never raises.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g., "app_started", "mcp_tool_called")
|
||||
properties: Optional event properties
|
||||
"""
|
||||
# Constraint: telemetry must never break the application
|
||||
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
|
||||
try:
|
||||
_get_client().track(event, properties or {})
|
||||
except Exception as e:
|
||||
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
|
||||
|
||||
|
||||
# --- First-Run Notice ---
|
||||
|
||||
|
||||
def show_notice_if_needed() -> None:
|
||||
"""Show one-time telemetry notice (Homebrew style).
|
||||
|
||||
Only shows if:
|
||||
- Telemetry is enabled
|
||||
- Notice hasn't been shown before
|
||||
|
||||
After showing, marks the notice as shown in config.
|
||||
"""
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
if config.telemetry_enabled and not config.telemetry_notice_shown:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
# Print to stderr so it doesn't interfere with command output
|
||||
console = Console(stderr=True)
|
||||
console.print(
|
||||
Panel(
|
||||
TELEMETRY_NOTICE.strip(),
|
||||
title="[dim]Telemetry Notice[/dim]",
|
||||
border_style="dim",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Mark as shown so we don't show again
|
||||
config.telemetry_notice_shown = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
|
||||
# --- Convenience Functions ---
|
||||
|
||||
|
||||
def track_app_started(mode: str) -> None:
|
||||
"""Track app startup.
|
||||
|
||||
Args:
|
||||
mode: "cli" or "mcp"
|
||||
"""
|
||||
track("app_started", {"mode": mode})
|
||||
|
||||
|
||||
def track_mcp_tool(tool_name: str) -> None:
|
||||
"""Track MCP tool usage.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "write_note", "search_notes")
|
||||
"""
|
||||
track("mcp_tool_called", {"tool": tool_name})
|
||||
|
||||
|
||||
def track_cli_command(command: str) -> None:
|
||||
"""Track CLI command usage.
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., "sync", "import claude")
|
||||
"""
|
||||
track("cli_command", {"command": command})
|
||||
|
||||
|
||||
def track_sync_completed(entity_count: int, duration_ms: int) -> None:
|
||||
"""Track sync completion.
|
||||
|
||||
Args:
|
||||
entity_count: Number of entities synced
|
||||
duration_ms: Duration in milliseconds
|
||||
"""
|
||||
track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
|
||||
|
||||
|
||||
def track_import_completed(source: str, count: int) -> None:
|
||||
"""Track import completion.
|
||||
|
||||
Args:
|
||||
source: Import source (e.g., "claude", "chatgpt")
|
||||
count: Number of items imported
|
||||
"""
|
||||
track("import_completed", {"source": source, "count": count})
|
||||
|
||||
|
||||
def track_error(error_type: str, message: str) -> None:
|
||||
"""Track an error (sanitized).
|
||||
|
||||
Args:
|
||||
error_type: Exception class name
|
||||
message: Error message (will be sanitized to remove file paths)
|
||||
"""
|
||||
if not message:
|
||||
track("error", {"type": error_type, "message": ""})
|
||||
return
|
||||
|
||||
# Sanitize file paths to prevent leaking user directory structure
|
||||
# Unix paths: /Users/name/file.py, /home/user/notes/doc.md
|
||||
sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
|
||||
# Windows paths: C:\Users\name\file.py, D:\projects\doc.md
|
||||
sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
|
||||
|
||||
# Truncate to avoid sending too much data
|
||||
track("error", {"type": error_type, "message": sanitized[:200]})
|
||||
@@ -232,6 +232,8 @@ def setup_logging(
|
||||
if log_to_file:
|
||||
log_path = Path.home() / ".basic-memory" / "basic-memory.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Keep logging synchronous (enqueue=False) to avoid background logging threads.
|
||||
# Background threads are a common source of "hang on exit" issues in CLI/test runs.
|
||||
logger.add(
|
||||
str(log_path),
|
||||
level=log_level,
|
||||
@@ -239,7 +241,7 @@ def setup_logging(
|
||||
retention="10 days",
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
enqueue=True, # Thread-safe async logging
|
||||
enqueue=False,
|
||||
colorize=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -146,6 +146,11 @@ async def engine_factory(
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# Set module-level state to prevent MCP lifespan from re-initializing
|
||||
# This ensures get_or_create_db() sees an existing engine and skips initialization
|
||||
db._engine = engine
|
||||
db._session_maker = session_maker
|
||||
|
||||
# Drop and recreate all tables for test isolation
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE"))
|
||||
@@ -158,7 +163,10 @@ async def engine_factory(
|
||||
|
||||
yield engine, session_maker
|
||||
|
||||
# Clean up module-level state
|
||||
await engine.dispose()
|
||||
db._engine = None
|
||||
db._session_maker = None
|
||||
|
||||
else:
|
||||
# SQLite: Create fresh database (fast with tmp files)
|
||||
@@ -236,6 +244,7 @@ def app_config(
|
||||
default_project_mode=False, # Match real-world usage - tools must pass explicit project
|
||||
update_permalinks_on_move=True,
|
||||
cloud_mode=False, # Explicitly disable cloud mode
|
||||
sync_changes=False, # Disable file sync in tests - prevents lifespan from starting blocking task
|
||||
database_backend=database_backend,
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
@@ -66,5 +66,3 @@ def test_lifespan_shutdown_awaits_sync_task_cancellation(app, monkeypatch):
|
||||
# Use asyncio.run to match the CLI/MCP execution model where loop teardown
|
||||
# would hang if a background task is left running.
|
||||
asyncio.run(_run_client_once())
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ async def test_project_metadata_consistency(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_basic_operation(mcp_server, app, test_project):
|
||||
async def test_create_project_basic_operation(mcp_server, app, test_project, tmp_path):
|
||||
"""Test creating a new project with basic parameters."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -65,7 +65,9 @@ async def test_create_project_basic_operation(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "test-new-project",
|
||||
"project_path": "/tmp/test-new-project",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-new-project"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -88,7 +90,7 @@ async def test_create_project_basic_operation(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_with_default_flag(mcp_server, app, test_project):
|
||||
async def test_create_project_with_default_flag(mcp_server, app, test_project, tmp_path):
|
||||
"""Test creating a project and setting it as default."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -97,7 +99,9 @@ async def test_create_project_with_default_flag(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "test-default-project",
|
||||
"project_path": "/tmp/test-default-project",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-default-project"
|
||||
),
|
||||
"set_default": True,
|
||||
},
|
||||
)
|
||||
@@ -116,7 +120,7 @@ async def test_create_project_with_default_flag(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_duplicate_name(mcp_server, app, test_project):
|
||||
async def test_create_project_duplicate_name(mcp_server, app, test_project, tmp_path):
|
||||
"""Test creating a project with duplicate name shows error."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -125,7 +129,9 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "duplicate-test",
|
||||
"project_path": "/tmp/duplicate-test-1",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-1"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,7 +141,9 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "duplicate-test",
|
||||
"project_path": "/tmp/duplicate-test-2",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-2"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -150,7 +158,7 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_basic_operation(mcp_server, app, test_project):
|
||||
async def test_delete_project_basic_operation(mcp_server, app, test_project, tmp_path):
|
||||
"""Test deleting a project that exists."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -159,7 +167,9 @@ async def test_delete_project_basic_operation(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "to-be-deleted",
|
||||
"project_path": "/tmp/to-be-deleted",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-to-be-deleted"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -240,12 +250,14 @@ async def test_delete_current_project_protection(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_lifecycle_workflow(mcp_server, app, test_project):
|
||||
async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_path):
|
||||
"""Test complete project lifecycle: create, switch, use, delete."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
project_name = "lifecycle-test"
|
||||
project_path = "/tmp/lifecycle-test"
|
||||
project_path = str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-lifecycle-test"
|
||||
)
|
||||
|
||||
# 1. Create new project
|
||||
create_result = await client.call_tool(
|
||||
@@ -295,7 +307,7 @@ async def test_project_lifecycle_workflow(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_delete_project_edge_cases(mcp_server, app, test_project):
|
||||
async def test_create_delete_project_edge_cases(mcp_server, app, test_project, tmp_path):
|
||||
"""Test edge cases for create and delete project operations."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -307,7 +319,11 @@ async def test_create_delete_project_edge_cases(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": special_name,
|
||||
"project_path": "/tmp/test-project-with-special-chars",
|
||||
"project_path": str(
|
||||
tmp_path.parent
|
||||
/ (tmp_path.name + "-projects")
|
||||
/ "project-test-project-with-special-chars"
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
@@ -333,7 +349,7 @@ async def test_create_delete_project_edge_cases(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_project_switching(mcp_server, app, test_project):
|
||||
async def test_case_insensitive_project_switching(mcp_server, app, test_project, tmp_path):
|
||||
"""Test case-insensitive project switching with proper database lookup."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -343,7 +359,9 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project)
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"project_path": f"/tmp/{project_name}",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
@@ -384,7 +402,7 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_project_operations(mcp_server, app, test_project):
|
||||
async def test_case_insensitive_project_operations(mcp_server, app, test_project, tmp_path):
|
||||
"""Test that all project operations work correctly after case-insensitive switching."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -394,7 +412,9 @@ async def test_case_insensitive_project_operations(mcp_server, app, test_project
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"project_path": f"/tmp/{project_name}",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
@@ -465,7 +485,7 @@ async def test_case_insensitive_error_handling(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_preservation_in_project_list(mcp_server, app, test_project):
|
||||
async def test_case_preservation_in_project_list(mcp_server, app, test_project, tmp_path):
|
||||
"""Test that project names preserve their original case in listings."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
@@ -483,7 +503,9 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project):
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"project_path": f"/tmp/{project_name}",
|
||||
"project_path": str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -516,13 +538,15 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_project_paths_rejected(mcp_server, app, test_project):
|
||||
async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_path):
|
||||
"""Test that creating nested project paths is rejected with clear error message."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a parent project
|
||||
parent_name = "parent-project"
|
||||
parent_path = "/tmp/nested-test/parent"
|
||||
parent_path = str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent"
|
||||
)
|
||||
|
||||
await client.call_tool(
|
||||
"create_memory_project",
|
||||
@@ -534,7 +558,9 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project):
|
||||
|
||||
# Try to create a child project nested under the parent
|
||||
child_name = "child-project"
|
||||
child_path = "/tmp/nested-test/parent/child"
|
||||
child_path = str(
|
||||
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent/child"
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool(
|
||||
|
||||
@@ -16,7 +16,7 @@ from fastmcp import Client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_state_sync_after_default_change(
|
||||
mcp_server, app, config_manager, test_project
|
||||
mcp_server, app, config_manager, test_project, tmp_path
|
||||
):
|
||||
"""Test that MCP session stays in sync when default project is changed."""
|
||||
|
||||
@@ -26,7 +26,7 @@ async def test_project_state_sync_after_default_change(
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "minerva",
|
||||
"project_path": "/tmp/minerva-test-project",
|
||||
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "minerva"),
|
||||
"set_default": False, # Don't set as default yet
|
||||
},
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
|
||||
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Could not resolve identifier" in response.json()["detail"]
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -8,6 +8,7 @@ from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectStatusResponse
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -249,3 +250,85 @@ async def test_update_project_active_status(
|
||||
assert response.status_code == 200
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
assert status_response.status == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_by_name(client: AsyncClient, test_project: Project, v2_projects_url):
|
||||
"""Test resolving a project by name returns correct project ID."""
|
||||
resolve_data = {"identifier": test_project.name}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
assert resolved.project_id == test_project.id
|
||||
assert resolved.name == test_project.name
|
||||
assert resolved.path == test_project.path
|
||||
assert resolved.is_default == (test_project.is_default or False)
|
||||
# Resolution method could be "name" or "permalink" depending on whether name == permalink
|
||||
assert resolved.resolution_method in ["name", "permalink"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_by_permalink(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Test resolving a project by permalink returns correct project ID."""
|
||||
# Assume test_project.name can be converted to permalink
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
project_permalink = generate_permalink(test_project.name)
|
||||
resolve_data = {"identifier": project_permalink}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
assert resolved.project_id == test_project.id
|
||||
assert resolved.name == test_project.name
|
||||
# Resolution method could be "name" or "permalink" depending on implementation
|
||||
assert resolved.resolution_method in ["name", "permalink"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
|
||||
"""Test resolving a project by ID string returns correct project ID."""
|
||||
resolve_data = {"identifier": str(test_project.id)}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
assert resolved.project_id == test_project.id
|
||||
assert resolved.name == test_project.name
|
||||
assert resolved.resolution_method == "id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_case_insensitive(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Test resolving a project by name is case-insensitive."""
|
||||
resolve_data = {"identifier": test_project.name.upper()}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
assert resolved.project_id == test_project.id
|
||||
assert resolved.name == test_project.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_not_found(client: AsyncClient, v2_projects_url):
|
||||
"""Test resolving a non-existent project returns 404."""
|
||||
resolve_data = {"identifier": "nonexistent-project"}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_empty_identifier(client: AsyncClient, v2_projects_url):
|
||||
"""Test resolving with empty identifier returns 422."""
|
||||
resolve_data = {"identifier": ""}
|
||||
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Regression tests for CLI command exit behavior.
|
||||
|
||||
These tests verify that CLI commands exit cleanly without hanging,
|
||||
which was a bug fixed in the database initialization refactor.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bm_version_exits_cleanly():
|
||||
"""Test that 'bm --version' exits cleanly within timeout."""
|
||||
# Use uv run to ensure correct environment
|
||||
result = subprocess.run(
|
||||
["uv", "run", "bm", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=Path(__file__).parent.parent.parent, # Project root
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Basic Memory version:" in result.stdout
|
||||
|
||||
|
||||
def test_bm_help_exits_cleanly():
|
||||
"""Test that 'bm --help' exits cleanly within timeout."""
|
||||
result = subprocess.run(
|
||||
["uv", "run", "bm", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=Path(__file__).parent.parent.parent,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Basic Memory" in result.stdout
|
||||
|
||||
|
||||
def test_bm_tool_help_exits_cleanly():
|
||||
"""Test that 'bm tool --help' exits cleanly within timeout."""
|
||||
result = subprocess.run(
|
||||
["uv", "run", "bm", "tool", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=Path(__file__).parent.parent.parent,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "tool" in result.stdout.lower()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Test that CLI tool commands exit cleanly without hanging.
|
||||
|
||||
This test ensures that CLI commands properly clean up database connections
|
||||
on exit, preventing process hangs. See GitHub issue for details.
|
||||
|
||||
The issue occurs when:
|
||||
1. ensure_initialization() calls asyncio.run(initialize_app())
|
||||
2. initialize_app() creates global database connections via db.get_or_create_db()
|
||||
3. When asyncio.run() completes, the event loop closes
|
||||
4. But the global database engine holds async connections that prevent clean exit
|
||||
5. Process hangs indefinitely
|
||||
|
||||
The fix ensures db.shutdown_db() is called before asyncio.run() returns.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCLIToolExit:
|
||||
"""Test that CLI tool commands exit cleanly."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
["tool", "--help"],
|
||||
["tool", "write-note", "--help"],
|
||||
["tool", "read-note", "--help"],
|
||||
["tool", "search-notes", "--help"],
|
||||
["tool", "build-context", "--help"],
|
||||
],
|
||||
)
|
||||
def test_cli_command_exits_cleanly(self, command: list[str]):
|
||||
"""Test that CLI commands exit without hanging.
|
||||
|
||||
Each command should complete within the timeout without requiring
|
||||
manual termination (Ctrl+C).
|
||||
"""
|
||||
full_command = [sys.executable, "-m", "basic_memory.cli.main"] + command
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
full_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10.0, # 10 second timeout - commands should complete in ~2s
|
||||
)
|
||||
# Command should exit with code 0 for --help
|
||||
assert result.returncode == 0, f"Command failed: {result.stderr}"
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail(
|
||||
f"Command '{' '.join(command)}' hung and did not exit within timeout. "
|
||||
"This indicates database connections are not being cleaned up properly."
|
||||
)
|
||||
|
||||
def test_ensure_initialization_exits_cleanly(self):
|
||||
"""Test that ensure_initialization doesn't cause process hang.
|
||||
|
||||
This test directly tests the initialization function that's called
|
||||
by CLI commands, ensuring it cleans up database connections properly.
|
||||
"""
|
||||
code = """
|
||||
import asyncio
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
ensure_initialization(app_config)
|
||||
print("OK")
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
assert "OK" in result.stdout, f"Unexpected output: {result.stdout}"
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail(
|
||||
"ensure_initialization() caused process hang. "
|
||||
"Database connections are not being cleaned up before event loop closes."
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from typing import AsyncGenerator
|
||||
from unittest.mock import patch
|
||||
|
||||
import nest_asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -484,14 +485,13 @@ def test_ensure_migrations_functionality(mock_initialize_database, app_config, m
|
||||
|
||||
|
||||
@patch("basic_memory.services.initialization.initialize_database")
|
||||
def test_ensure_migrations_handles_errors(mock_initialize_database, app_config, monkeypatch):
|
||||
"""Test that initialization handles errors gracefully."""
|
||||
def test_ensure_migrations_propagates_errors(mock_initialize_database, app_config, monkeypatch):
|
||||
"""Test that initialization errors propagate to caller."""
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
# Configure mock to raise an exception
|
||||
mock_initialize_database.side_effect = Exception("Test error")
|
||||
|
||||
# Call the function - should not raise exception
|
||||
ensure_initialization(app_config)
|
||||
|
||||
# We're just making sure it doesn't crash by calling it
|
||||
# Call the function - should raise exception
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
ensure_initialization(app_config)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Test that imported conversations are properly indexed with correct permalink and title.
|
||||
|
||||
This test verifies issue #452 - Imported conversations not indexed correctly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
from basic_memory.sync.sync_service import SyncService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_imported_conversations_have_correct_permalink_and_title(
|
||||
project_config: ProjectConfig,
|
||||
sync_service: SyncService,
|
||||
entity_service: EntityService,
|
||||
entity_repository: EntityRepository,
|
||||
search_service: SearchService,
|
||||
):
|
||||
"""Test that imported conversations have correct permalink and title after sync.
|
||||
|
||||
Issue #452: Imported conversations show permalink: null in search results
|
||||
and title shows as filename instead of frontmatter title.
|
||||
"""
|
||||
base_path = project_config.home
|
||||
|
||||
# Create parser and processor for importer
|
||||
parser = EntityParser(base_path)
|
||||
processor = MarkdownProcessor(parser)
|
||||
|
||||
# Create importer
|
||||
importer = ClaudeConversationsImporter(base_path, processor)
|
||||
|
||||
# Sample conversation data
|
||||
conversations = [
|
||||
{
|
||||
"uuid": "test-123",
|
||||
"name": "My Test Conversation Title",
|
||||
"created_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T11:00:00Z",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "msg-1",
|
||||
"sender": "human",
|
||||
"created_at": "2025-01-15T10:00:00Z",
|
||||
"text": "Hello world",
|
||||
"content": [{"type": "text", "text": "Hello world"}],
|
||||
"attachments": [],
|
||||
},
|
||||
{
|
||||
"uuid": "msg-2",
|
||||
"sender": "assistant",
|
||||
"created_at": "2025-01-15T10:01:00Z",
|
||||
"text": "Hello!",
|
||||
"content": [{"type": "text", "text": "Hello!"}],
|
||||
"attachments": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Run import
|
||||
result = await importer.import_data(conversations, "conversations")
|
||||
assert result.success, f"Import failed: {result}"
|
||||
assert result.conversations == 1
|
||||
|
||||
# Verify the file was created with correct content
|
||||
conv_path = base_path / "conversations" / "20250115-My_Test_Conversation_Title.md"
|
||||
assert conv_path.exists(), f"Expected file at {conv_path}"
|
||||
|
||||
content = conv_path.read_text()
|
||||
assert "---" in content, "File should have frontmatter markers"
|
||||
assert "title: My Test Conversation Title" in content, "File should have title in frontmatter"
|
||||
assert "permalink: conversations/20250115-My_Test_Conversation_Title" in content, (
|
||||
"File should have permalink in frontmatter"
|
||||
)
|
||||
|
||||
# Run sync to index the imported file
|
||||
await sync_service.sync(base_path, project_config.name)
|
||||
|
||||
# Verify entity in database
|
||||
entities = await entity_repository.find_all()
|
||||
assert len(entities) == 1, f"Expected 1 entity, got {len(entities)}"
|
||||
|
||||
entity = entities[0]
|
||||
|
||||
# These are the key assertions for issue #452
|
||||
assert entity.title == "My Test Conversation Title", (
|
||||
f"Title should be from frontmatter, got: {entity.title}"
|
||||
)
|
||||
assert entity.permalink == "conversations/20250115-My_Test_Conversation_Title", (
|
||||
f"Permalink should be from frontmatter, got: {entity.permalink}"
|
||||
)
|
||||
|
||||
# Verify search index also has correct data
|
||||
results = await search_service.search(SearchQuery(text="Test Conversation"))
|
||||
assert len(results) >= 1, "Should find the conversation in search"
|
||||
|
||||
# Find our entity in search results
|
||||
search_result = next((r for r in results if r.entity_id == entity.id), None)
|
||||
assert search_result is not None, "Entity should be in search results"
|
||||
assert search_result.title == "My Test Conversation Title", (
|
||||
f"Search title should be from frontmatter, got: {search_result.title}"
|
||||
)
|
||||
assert search_result.permalink == "conversations/20250115-My_Test_Conversation_Title", (
|
||||
f"Search permalink should not be null, got: {search_result.permalink}"
|
||||
)
|
||||
@@ -23,6 +23,9 @@ def test_clean_filename():
|
||||
# Test with empty string
|
||||
assert clean_filename("") == "untitled"
|
||||
|
||||
# Test with None (fixes #451 - ChatGPT null titles)
|
||||
assert clean_filename(None) == "untitled"
|
||||
|
||||
# Test with only special characters
|
||||
# Some implementations may return empty string or underscore
|
||||
result = clean_filename("!@#$%^&*()")
|
||||
|
||||
@@ -85,12 +85,15 @@ async def test_parse_complete_file(project_config, entity_parser, valid_entity_c
|
||||
), "missing [[Auth API Spec]]"
|
||||
|
||||
# inline links in content
|
||||
assert Relation(type="links_to", target="Random Link", context=None) in entity.relations, (
|
||||
"missing [[Random Link]]"
|
||||
)
|
||||
# Note: CI environment returns 'links_to' while local may return 'links to'
|
||||
assert (
|
||||
Relation(type="links_to", target="Random Link with Title|Titled Link", context=None)
|
||||
in entity.relations
|
||||
Relation(type="links_to", target="Random Link", context=None) in entity.relations
|
||||
or Relation(type="links to", target="Random Link", context=None) in entity.relations
|
||||
), "missing [[Random Link]]"
|
||||
assert Relation(
|
||||
type="links_to", target="Random Link with Title|Titled Link", context=None
|
||||
) in entity.relations or Relation(
|
||||
type="links to", target="Random Link with Title|Titled Link", context=None
|
||||
), "missing [[Random Link with Title|Titled Link]]"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for project context utilities."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestResolveProjectParameter:
|
||||
"""Tests for resolve_project_parameter function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_requires_project_by_default(self):
|
||||
"""In cloud mode, project is required when allow_discovery=False."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = True
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await resolve_project_parameter(project=None, allow_discovery=False)
|
||||
|
||||
assert "No project specified" in str(exc_info.value)
|
||||
assert "Project is required for cloud mode" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_allows_discovery_when_enabled(self):
|
||||
"""In cloud mode with allow_discovery=True, returns None instead of error."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = True
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
result = await resolve_project_parameter(project=None, allow_discovery=True)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_returns_project_when_specified(self):
|
||||
"""In cloud mode, returns the specified project."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = True
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
result = await resolve_project_parameter(project="my-project")
|
||||
|
||||
assert result == "my-project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_uses_env_var_priority(self):
|
||||
"""In local mode, BASIC_MEMORY_MCP_PROJECT env var takes priority."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = False
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
with patch.dict(os.environ, {"BASIC_MEMORY_MCP_PROJECT": "env-project"}):
|
||||
result = await resolve_project_parameter(project="explicit-project")
|
||||
|
||||
# Env var should take priority over explicit project
|
||||
assert result == "env-project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_uses_explicit_project(self):
|
||||
"""In local mode without env var, uses explicit project parameter."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = False
|
||||
mock_config.default_project_mode = False
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove the env var if it exists
|
||||
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
|
||||
result = await resolve_project_parameter(project="explicit-project")
|
||||
|
||||
assert result == "explicit-project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_uses_default_project(self):
|
||||
"""In local mode with default_project_mode, uses default project."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = False
|
||||
mock_config.default_project_mode = True
|
||||
mock_config.default_project = "default-project"
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
|
||||
result = await resolve_project_parameter(project=None)
|
||||
|
||||
assert result == "default-project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_returns_none_when_no_resolution(self):
|
||||
"""In local mode without any project source, returns None."""
|
||||
from basic_memory.mcp.project_context import resolve_project_parameter
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.cloud_mode = False
|
||||
mock_config.default_project_mode = False
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.project_context.ConfigManager"
|
||||
) as mock_config_manager:
|
||||
mock_config_manager.return_value.config = mock_config
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
|
||||
result = await resolve_project_parameter(project=None)
|
||||
|
||||
assert result is None
|
||||
@@ -915,22 +915,19 @@ class TestMoveNoteErrorHandling:
|
||||
with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
mock_get_project.return_value.name = "test-project"
|
||||
mock_get_project.return_value.id = "test-project-id"
|
||||
mock_get_project.return_value.home = Path("/tmp/test")
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_post",
|
||||
"basic_memory.mcp.tools.move_note.resolve_entity_id",
|
||||
side_effect=Exception("entity not found"),
|
||||
):
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_get",
|
||||
side_effect=Exception("not found"),
|
||||
):
|
||||
result = await move_note.fn(
|
||||
"test-note", "target/file.md", project="test-project"
|
||||
)
|
||||
result = await move_note.fn(
|
||||
"test-note", "target/file.md", project="test-project"
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Note Not Found" in result
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Note Not Found" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_permission_error_handling(self, mock_client):
|
||||
@@ -941,19 +938,16 @@ class TestMoveNoteErrorHandling:
|
||||
with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
mock_get_project.return_value.name = "test-project"
|
||||
mock_get_project.return_value.id = "test-project-id"
|
||||
mock_get_project.return_value.home = Path("/tmp/test")
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_post",
|
||||
"basic_memory.mcp.tools.move_note.resolve_entity_id",
|
||||
side_effect=Exception("permission denied"),
|
||||
):
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_get",
|
||||
side_effect=Exception("not found"),
|
||||
):
|
||||
result = await move_note.fn(
|
||||
"test-note", "target/file.md", project="test-project"
|
||||
)
|
||||
result = await move_note.fn(
|
||||
"test-note", "target/file.md", project="test-project"
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Permission Error" in result
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Permission Error" in result
|
||||
|
||||
@@ -138,12 +138,17 @@ class TestReadContentSecurityValidation:
|
||||
for safe_path in safe_paths:
|
||||
# Mock the API call to simulate a successful response
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
|
||||
mock_response.text = f"# Content for {safe_path}\nThis is test content."
|
||||
mock_call_get.return_value = mock_response
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "text/markdown",
|
||||
"content-length": "100",
|
||||
}
|
||||
mock_response.text = f"# Content for {safe_path}\nThis is test content."
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(project=test_project.name, path=safe_path)
|
||||
result = await read_content.fn(project=test_project.name, path=safe_path)
|
||||
|
||||
# Should succeed (not a security error)
|
||||
assert isinstance(result, dict)
|
||||
@@ -189,12 +194,14 @@ class TestReadContentSecurityValidation:
|
||||
"""Test that empty path is handled securely."""
|
||||
# Mock the API call since empty path should be allowed (resolves to project root)
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "50"}
|
||||
mock_response.text = "# Root content"
|
||||
mock_call_get.return_value = mock_response
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "50"}
|
||||
mock_response.text = "# Root content"
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(project=test_project.name, path="")
|
||||
result = await read_content.fn(project=test_project.name, path="")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
# Empty path should not trigger security error (it's handled as project root)
|
||||
@@ -217,12 +224,17 @@ class TestReadContentSecurityValidation:
|
||||
for safe_path in safe_paths:
|
||||
# Mock the API call for these safe paths
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
|
||||
mock_response.text = f"# Content for {safe_path}"
|
||||
mock_call_get.return_value = mock_response
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "text/markdown",
|
||||
"content-length": "100",
|
||||
}
|
||||
mock_response.text = f"# Content for {safe_path}"
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(project=test_project.name, path=safe_path)
|
||||
result = await read_content.fn(project=test_project.name, path=safe_path)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
# Should NOT contain security error message
|
||||
@@ -249,50 +261,58 @@ class TestReadContentFunctionality:
|
||||
|
||||
# Mock the API call to simulate reading the file
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
|
||||
mock_response.text = "# Test Document\nThis is test content for reading."
|
||||
mock_call_get.return_value = mock_response
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
|
||||
mock_response.text = "# Test Document\nThis is test content for reading."
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(project=test_project.name, path="docs/test-document.md")
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="docs/test-document.md"
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "text"
|
||||
assert "Test Document" in result["text"]
|
||||
assert result["content_type"] == "text/markdown"
|
||||
assert result["encoding"] == "utf-8"
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "text"
|
||||
assert "Test Document" in result["text"]
|
||||
assert result["content_type"] == "text/markdown"
|
||||
assert result["encoding"] == "utf-8"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_content_image_file_handling(self, client, test_project):
|
||||
"""Test reading an image file with security validation."""
|
||||
# Mock the API call to simulate reading an image
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
# Create a simple fake image data
|
||||
fake_image_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
# Create a simple fake image data
|
||||
fake_image_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "image/png",
|
||||
"content-length": str(len(fake_image_data)),
|
||||
}
|
||||
mock_response.content = fake_image_data
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "image/png",
|
||||
"content-length": str(len(fake_image_data)),
|
||||
}
|
||||
mock_response.content = fake_image_data
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
# Mock PIL Image processing
|
||||
with patch("basic_memory.mcp.tools.read_content.PILImage") as mock_pil:
|
||||
mock_img = MagicMock()
|
||||
mock_img.width = 100
|
||||
mock_img.height = 100
|
||||
mock_img.mode = "RGB"
|
||||
mock_img.getbands.return_value = ["R", "G", "B"]
|
||||
mock_pil.open.return_value = mock_img
|
||||
# Mock PIL Image processing
|
||||
with patch("basic_memory.mcp.tools.read_content.PILImage") as mock_pil:
|
||||
mock_img = MagicMock()
|
||||
mock_img.width = 100
|
||||
mock_img.height = 100
|
||||
mock_img.mode = "RGB"
|
||||
mock_img.getbands.return_value = ["R", "G", "B"]
|
||||
mock_pil.open.return_value = mock_img
|
||||
|
||||
with patch("basic_memory.mcp.tools.read_content.optimize_image") as mock_optimize:
|
||||
mock_optimize.return_value = b"optimized_image_data"
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.read_content.optimize_image"
|
||||
) as mock_optimize:
|
||||
mock_optimize.return_value = b"optimized_image_data"
|
||||
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="assets/safe-image.png"
|
||||
)
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="assets/safe-image.png"
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "image"
|
||||
@@ -305,23 +325,28 @@ class TestReadContentFunctionality:
|
||||
"""Test reading content with explicit project parameter."""
|
||||
# Mock the API call and project configuration
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.read_content.get_active_project"
|
||||
) as mock_get_project:
|
||||
# Mock project configuration
|
||||
mock_project = MagicMock()
|
||||
mock_project.project_url = "http://test"
|
||||
mock_project.home = Path("/test/project")
|
||||
mock_get_project.return_value = mock_project
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.read_content.get_active_project"
|
||||
) as mock_get_project:
|
||||
# Mock project configuration
|
||||
mock_project = MagicMock()
|
||||
mock_project.id = 1 # Set project ID for v2 API
|
||||
mock_project.project_url = "http://test"
|
||||
mock_project.home = Path("/test/project")
|
||||
mock_get_project.return_value = mock_project
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/plain", "content-length": "50"}
|
||||
mock_response.text = "Project-specific content"
|
||||
mock_call_get.return_value = mock_response
|
||||
# Mock resolve_entity_id to return an entity ID
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(
|
||||
path="notes/project-file.txt", project="specific-project"
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/plain", "content-length": "50"}
|
||||
mock_response.text = "Project-specific content"
|
||||
mock_call_get.return_value = mock_response
|
||||
|
||||
result = await read_content.fn(
|
||||
path="notes/project-file.txt", project="specific-project"
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "text"
|
||||
@@ -332,41 +357,47 @@ class TestReadContentFunctionality:
|
||||
"""Test handling of nonexistent files (after security validation)."""
|
||||
# Mock API call to return 404
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
mock_call_get.side_effect = Exception("File not found")
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
mock_call_get.side_effect = Exception("File not found")
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
# This should pass security validation but fail on API call
|
||||
try:
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="docs/nonexistent-file.md"
|
||||
)
|
||||
# If no exception is raised, check the result format
|
||||
assert isinstance(result, dict)
|
||||
except Exception as e:
|
||||
# Exception due to API failure is acceptable for this test
|
||||
assert "File not found" in str(e)
|
||||
# This should pass security validation but fail on API call
|
||||
try:
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="docs/nonexistent-file.md"
|
||||
)
|
||||
# If no exception is raised, check the result format
|
||||
assert isinstance(result, dict)
|
||||
except Exception as e:
|
||||
# Exception due to API failure is acceptable for this test
|
||||
assert "File not found" in str(e)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_content_binary_file_handling(self, client, test_project):
|
||||
"""Test reading binary files with security validation."""
|
||||
# Mock the API call to simulate reading a binary file
|
||||
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
|
||||
binary_data = b"Binary file content with special bytes: \x00\x01\x02\x03"
|
||||
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
|
||||
binary_data = b"Binary file content with special bytes: \x00\x01\x02\x03"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": str(len(binary_data)),
|
||||
}
|
||||
mock_response.content = binary_data
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": str(len(binary_data)),
|
||||
}
|
||||
mock_response.content = binary_data
|
||||
mock_call_get.return_value = mock_response
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
result = await read_content.fn(project=test_project.name, path="files/safe-binary.bin")
|
||||
result = await read_content.fn(
|
||||
project=test_project.name, path="files/safe-binary.bin"
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "document"
|
||||
assert "source" in result
|
||||
assert result["source"]["type"] == "base64"
|
||||
assert result["source"]["media_type"] == "application/octet-stream"
|
||||
assert isinstance(result, dict)
|
||||
assert result["type"] == "document"
|
||||
assert "source" in result
|
||||
assert result["source"]["type"] == "base64"
|
||||
assert result["source"]["media_type"] == "application/octet-stream"
|
||||
|
||||
|
||||
class TestReadContentEdgeCases:
|
||||
|
||||
@@ -54,16 +54,12 @@ async def test_note_unicode_content(app, test_project):
|
||||
project=test_project.name, title="Unicode Test", folder="test", content=content
|
||||
)
|
||||
|
||||
assert (
|
||||
dedent(f"""
|
||||
# Created note
|
||||
project: {test_project.name}
|
||||
file_path: test/Unicode Test.md
|
||||
permalink: test/unicode-test
|
||||
checksum: 272389cd
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
# Check that note was created (checksum is now "unknown" in v2)
|
||||
assert "# Created note" in result
|
||||
assert f"project: {test_project.name}" in result
|
||||
assert "file_path: test/Unicode Test.md" in result
|
||||
assert "permalink: test/unicode-test" in result
|
||||
assert "checksum:" in result # Checksum exists but may be "unknown"
|
||||
|
||||
# Read back should preserve unicode
|
||||
result = await read_note.fn("test/unicode-test", project=test_project.name)
|
||||
@@ -72,7 +68,7 @@ async def test_note_unicode_content(app, test_project):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_notes(app, test_project):
|
||||
"""Test creating and managing multiple"""
|
||||
"""Test creating and managing multiple notes"""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
@@ -85,29 +81,19 @@ async def test_multiple_notes(app, test_project):
|
||||
project=test_project.name, title=title, folder=folder, content=content, tags=tags
|
||||
)
|
||||
|
||||
# Should be able to read each one
|
||||
# Should be able to read each one individually
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await read_note.fn(permalink, project=test_project.name)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once
|
||||
|
||||
result = await read_note.fn("test/*", project=test_project.name)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
assert "--- memory://test/note-3" in result
|
||||
assert "Content 3" in result
|
||||
# Note: v2 API does not support glob patterns in read_note
|
||||
# Glob patterns should be used with build_context or list_directory instead
|
||||
# For reading multiple notes, use build_context with memory:// URLs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_notes_pagination(app, test_project):
|
||||
"""Test creating and managing multiple"""
|
||||
"""Test reading individual notes (pagination applies to single note content)"""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
@@ -120,20 +106,14 @@ async def test_multiple_notes_pagination(app, test_project):
|
||||
project=test_project.name, title=title, folder=folder, content=content, tags=tags
|
||||
)
|
||||
|
||||
# Should be able to read each one
|
||||
# Should be able to read each one individually with pagination
|
||||
# Note: pagination now applies to single note content, not multiple notes
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await read_note.fn(permalink, project=test_project.name)
|
||||
note = await read_note.fn(permalink, page=1, page_size=10, project=test_project.name)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once with pagination
|
||||
result = await read_note.fn("test/*", page=1, page_size=2, project=test_project.name)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
# Note: v2 API does not support glob patterns in read_note
|
||||
# For reading multiple notes, use build_context or list_directory instead
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -268,14 +268,21 @@ async def test_view_note_direct_success(app, test_project, mock_call_get):
|
||||
mock_response.text = note_content
|
||||
mock_call_get.return_value = mock_response
|
||||
|
||||
# Call the function
|
||||
result = await view_note.fn("test/test-note", project=test_project.name)
|
||||
# Mock resolve_entity_id for v2 API
|
||||
with patch("basic_memory.mcp.tools.read_note.resolve_entity_id") as mock_resolve:
|
||||
mock_resolve.return_value = 123
|
||||
|
||||
# Verify direct lookup was used
|
||||
mock_call_get.assert_called_once()
|
||||
assert "test/test-note" in mock_call_get.call_args[0][1]
|
||||
# Call the function
|
||||
result = await view_note.fn("test/test-note", project=test_project.name)
|
||||
|
||||
# Verify result contains note content
|
||||
assert 'Note retrieved: "test/test-note"' in result
|
||||
assert "Display this note as a markdown artifact for the user" in result
|
||||
assert "This is a test note." in result
|
||||
# Verify direct lookup was used
|
||||
mock_call_get.assert_called_once()
|
||||
assert (
|
||||
"test/test-note" in mock_call_get.call_args[0][1]
|
||||
or "/resource/123" in mock_call_get.call_args[0][1]
|
||||
)
|
||||
|
||||
# Verify result contains note content
|
||||
assert 'Note retrieved: "test/test-note"' in result
|
||||
assert "Display this note as a markdown artifact for the user" in result
|
||||
assert "This is a test note." in result
|
||||
|
||||
@@ -466,3 +466,45 @@ class TestTimeframeParsing:
|
||||
# They should be approximately the same time (within an hour due to parsing differences)
|
||||
time_diff = abs((today_parsed - oneday_parsed).total_seconds())
|
||||
assert time_diff < 3600, f"'today' and '1d' should be similar times, diff: {time_diff}s"
|
||||
|
||||
|
||||
class TestObservationContentLength:
|
||||
"""Test observation content length validation matches DB schema."""
|
||||
|
||||
def test_observation_accepts_long_content(self):
|
||||
"""Observation content should accept unlimited length to match DB Text column."""
|
||||
from basic_memory.schemas.base import Observation
|
||||
|
||||
# Very long content that would have failed with old MaxLen(1000) limit
|
||||
long_content = "x" * 10000
|
||||
|
||||
obs = Observation(category="test", content=long_content)
|
||||
assert len(obs.content) == 10000
|
||||
|
||||
def test_observation_accepts_very_long_content(self):
|
||||
"""Observation content should accept very long content like JSON schemas."""
|
||||
from basic_memory.schemas.base import Observation
|
||||
|
||||
# Simulate the JSON schema content from issue #385 (1458+ chars)
|
||||
json_schema_content = '{"$schema": "http://json-schema.org/draft-07/schema#"' + "x" * 50000
|
||||
|
||||
obs = Observation(category="schema", content=json_schema_content)
|
||||
assert len(obs.content) > 50000
|
||||
|
||||
def test_observation_still_requires_non_empty_content(self):
|
||||
"""Observation content must still be non-empty after stripping."""
|
||||
from basic_memory.schemas.base import Observation
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Observation(category="test", content="")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Observation(category="test", content=" ") # whitespace only
|
||||
|
||||
def test_observation_strips_whitespace(self):
|
||||
"""Observation content should have whitespace stripped."""
|
||||
from basic_memory.schemas.base import Observation
|
||||
|
||||
obs = Observation(category="test", content=" some content ")
|
||||
assert obs.content == "some content"
|
||||
|
||||
@@ -8,7 +8,6 @@ from basic_memory.services.initialization import (
|
||||
ensure_initialization,
|
||||
initialize_database,
|
||||
reconcile_projects_with_config,
|
||||
initialize_file_sync,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +25,8 @@ async def test_initialize_database(mock_get_or_create_db, app_config):
|
||||
async def test_initialize_database_error(mock_get_or_create_db, app_config):
|
||||
"""Test handling errors during database initialization."""
|
||||
mock_get_or_create_db.side_effect = Exception("Test error")
|
||||
await initialize_database(app_config)
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
await initialize_database(app_config)
|
||||
mock_get_or_create_db.assert_called_once_with(app_config.database_path)
|
||||
|
||||
|
||||
@@ -120,129 +120,3 @@ async def test_reconcile_projects_with_error_handling(mock_get_db, app_config):
|
||||
mock_logger.info.assert_any_call(
|
||||
"Continuing with initialization despite synchronization error"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("basic_memory.services.initialization.db.get_or_create_db")
|
||||
@patch("basic_memory.sync.sync_service.get_sync_service")
|
||||
@patch("basic_memory.sync.WatchService")
|
||||
@patch("basic_memory.services.initialization.asyncio.create_task")
|
||||
async def test_initialize_file_sync_background_tasks(
|
||||
mock_create_task, mock_watch_service_class, mock_get_sync_service, mock_get_db, app_config
|
||||
):
|
||||
"""Test file sync initialization with background task processing."""
|
||||
# Setup mocks
|
||||
mock_session_maker = AsyncMock()
|
||||
mock_get_db.return_value = (None, mock_session_maker)
|
||||
|
||||
mock_watch_service = AsyncMock()
|
||||
mock_watch_service.run = AsyncMock()
|
||||
mock_watch_service_class.return_value = mock_watch_service
|
||||
|
||||
mock_repository = AsyncMock()
|
||||
mock_project1 = MagicMock()
|
||||
mock_project1.name = "project1"
|
||||
mock_project1.path = "/path/to/project1"
|
||||
mock_project1.id = 1
|
||||
|
||||
mock_project2 = MagicMock()
|
||||
mock_project2.name = "project2"
|
||||
mock_project2.path = "/path/to/project2"
|
||||
mock_project2.id = 2
|
||||
|
||||
mock_sync_service = AsyncMock()
|
||||
mock_sync_service.sync = AsyncMock()
|
||||
mock_get_sync_service.return_value = mock_sync_service
|
||||
|
||||
# Mock background tasks
|
||||
mock_task1 = MagicMock()
|
||||
mock_task2 = MagicMock()
|
||||
mock_create_task.side_effect = [mock_task1, mock_task2]
|
||||
|
||||
# Mock the repository
|
||||
with patch("basic_memory.services.initialization.ProjectRepository") as mock_repo_class:
|
||||
mock_repo_class.return_value = mock_repository
|
||||
mock_repository.get_active_projects.return_value = [mock_project1, mock_project2]
|
||||
|
||||
# Run the function
|
||||
result = await initialize_file_sync(app_config)
|
||||
|
||||
# Assertions
|
||||
mock_repository.get_active_projects.assert_called_once()
|
||||
|
||||
# Should create background tasks for each project (non-blocking)
|
||||
assert mock_create_task.call_count == 2
|
||||
|
||||
# Verify tasks were created but not awaited (function returns immediately)
|
||||
assert result is None
|
||||
|
||||
# Watch service should still be started
|
||||
mock_watch_service.run.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("basic_memory.services.initialization.db.get_or_create_db")
|
||||
@patch("basic_memory.sync.sync_service.get_sync_service")
|
||||
@patch("basic_memory.sync.WatchService")
|
||||
@patch("basic_memory.services.initialization.asyncio.create_task")
|
||||
@patch.dict("os.environ", {"BASIC_MEMORY_MCP_PROJECT": "project1"})
|
||||
async def test_initialize_file_sync_respects_project_constraint(
|
||||
mock_create_task, mock_watch_service_class, mock_get_sync_service, mock_get_db, app_config
|
||||
):
|
||||
"""Test that file sync only syncs the constrained project when BASIC_MEMORY_MCP_PROJECT is set."""
|
||||
# Setup mocks
|
||||
mock_session_maker = AsyncMock()
|
||||
mock_get_db.return_value = (None, mock_session_maker)
|
||||
|
||||
mock_watch_service = AsyncMock()
|
||||
mock_watch_service.run = AsyncMock()
|
||||
mock_watch_service_class.return_value = mock_watch_service
|
||||
|
||||
mock_repository = AsyncMock()
|
||||
mock_project1 = MagicMock()
|
||||
mock_project1.name = "project1"
|
||||
mock_project1.path = "/path/to/project1"
|
||||
mock_project1.id = 1
|
||||
|
||||
mock_project2 = MagicMock()
|
||||
mock_project2.name = "project2"
|
||||
mock_project2.path = "/path/to/project2"
|
||||
mock_project2.id = 2
|
||||
|
||||
mock_project3 = MagicMock()
|
||||
mock_project3.name = "project3"
|
||||
mock_project3.path = "/path/to/project3"
|
||||
mock_project3.id = 3
|
||||
|
||||
mock_sync_service = AsyncMock()
|
||||
mock_sync_service.sync = AsyncMock()
|
||||
mock_get_sync_service.return_value = mock_sync_service
|
||||
|
||||
# Mock background tasks
|
||||
mock_task = MagicMock()
|
||||
mock_create_task.return_value = mock_task
|
||||
|
||||
# Mock the repository
|
||||
with patch("basic_memory.services.initialization.ProjectRepository") as mock_repo_class:
|
||||
mock_repo_class.return_value = mock_repository
|
||||
# Return all 3 projects from get_active_projects
|
||||
mock_repository.get_active_projects.return_value = [
|
||||
mock_project1,
|
||||
mock_project2,
|
||||
mock_project3,
|
||||
]
|
||||
|
||||
# Run the function
|
||||
result = await initialize_file_sync(app_config)
|
||||
|
||||
# Assertions
|
||||
mock_repository.get_active_projects.assert_called_once()
|
||||
|
||||
# Should only create 1 background task for project1 (the constrained project)
|
||||
assert mock_create_task.call_count == 1
|
||||
|
||||
# Verify the function returns None
|
||||
assert result is None
|
||||
|
||||
# Watch service should still be started
|
||||
mock_watch_service.run.assert_called_once()
|
||||
|
||||
@@ -2055,3 +2055,65 @@ async def test_file_service_checksum_correctness(
|
||||
expected = hashlib.sha256(small_content.encode("utf-8")).hexdigest()
|
||||
assert checksum == expected
|
||||
assert len(checksum) == 64 # SHA256 hex digest length
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_file_not_found_gracefully(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
):
|
||||
"""Test that FileNotFoundError during sync is handled gracefully.
|
||||
|
||||
This tests the fix for issue #386 where files existing in the database
|
||||
but missing from the filesystem would crash the sync worker.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = project_config.home
|
||||
|
||||
# Create a test file
|
||||
test_file = project_dir / "missing_file.md"
|
||||
await create_test_file(
|
||||
test_file,
|
||||
dedent(
|
||||
"""
|
||||
---
|
||||
type: knowledge
|
||||
permalink: missing-file
|
||||
---
|
||||
# Missing File
|
||||
Content that will disappear
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# Sync to add entity to database
|
||||
await sync_service.sync(project_dir)
|
||||
|
||||
# Verify entity was created
|
||||
entity = await sync_service.entity_repository.get_by_file_path("missing_file.md")
|
||||
assert entity is not None
|
||||
assert entity.permalink == "missing-file"
|
||||
|
||||
# Delete the file but leave the entity in database (simulating inconsistency)
|
||||
test_file.unlink()
|
||||
|
||||
# Mock file_service methods to raise FileNotFoundError
|
||||
# (since the file doesn't exist, read operations will fail)
|
||||
async def mock_read_that_fails(*args, **kwargs):
|
||||
raise FileNotFoundError("Simulated file not found")
|
||||
|
||||
with patch.object(
|
||||
sync_service.file_service, "read_file_content", side_effect=mock_read_that_fails
|
||||
):
|
||||
# Force full scan to detect the file
|
||||
await force_full_scan(sync_service)
|
||||
|
||||
# Sync should handle the error gracefully and delete the orphaned entity
|
||||
await sync_service.sync(project_dir)
|
||||
|
||||
# Should not crash and should not have errors (FileNotFoundError is handled specially)
|
||||
# The file should be treated as deleted
|
||||
|
||||
# Entity should be deleted from database
|
||||
entity = await sync_service.entity_repository.get_by_file_path("missing_file.md")
|
||||
assert entity is None, "Orphaned entity should be deleted when file is not found"
|
||||
|
||||
@@ -483,3 +483,116 @@ class TestPlatformNativePathSeparators:
|
||||
else:
|
||||
# Unix: should have forward slashes
|
||||
assert "/" in main_path
|
||||
|
||||
|
||||
class TestFormattingConfig:
|
||||
"""Test file formatting configuration options."""
|
||||
|
||||
def test_format_on_save_defaults_to_false(self):
|
||||
"""Test that format_on_save is disabled by default."""
|
||||
config = BasicMemoryConfig()
|
||||
assert config.format_on_save is False
|
||||
|
||||
def test_format_on_save_can_be_enabled(self):
|
||||
"""Test that format_on_save can be set to True."""
|
||||
config = BasicMemoryConfig(format_on_save=True)
|
||||
assert config.format_on_save is True
|
||||
|
||||
def test_formatter_command_defaults_to_none(self):
|
||||
"""Test that formatter_command defaults to None (uses built-in mdformat)."""
|
||||
config = BasicMemoryConfig()
|
||||
assert config.formatter_command is None
|
||||
|
||||
def test_formatter_command_can_be_set(self):
|
||||
"""Test that formatter_command can be configured."""
|
||||
config = BasicMemoryConfig(formatter_command="prettier --write {file}")
|
||||
assert config.formatter_command == "prettier --write {file}"
|
||||
|
||||
def test_formatters_defaults_to_empty_dict(self):
|
||||
"""Test that formatters defaults to empty dict."""
|
||||
config = BasicMemoryConfig()
|
||||
assert config.formatters == {}
|
||||
|
||||
def test_formatters_can_be_configured(self):
|
||||
"""Test that per-extension formatters can be configured."""
|
||||
config = BasicMemoryConfig(
|
||||
formatters={
|
||||
"md": "prettier --write {file}",
|
||||
"json": "jq . {file} > {file}.tmp && mv {file}.tmp {file}",
|
||||
}
|
||||
)
|
||||
assert config.formatters["md"] == "prettier --write {file}"
|
||||
assert "json" in config.formatters
|
||||
|
||||
def test_formatter_timeout_defaults_to_5_seconds(self):
|
||||
"""Test that formatter_timeout defaults to 5.0 seconds."""
|
||||
config = BasicMemoryConfig()
|
||||
assert config.formatter_timeout == 5.0
|
||||
|
||||
def test_formatter_timeout_can_be_customized(self):
|
||||
"""Test that formatter_timeout can be set to a different value."""
|
||||
config = BasicMemoryConfig(formatter_timeout=10.0)
|
||||
assert config.formatter_timeout == 10.0
|
||||
|
||||
def test_formatter_timeout_must_be_positive(self):
|
||||
"""Test that formatter_timeout validation rejects non-positive values."""
|
||||
import pydantic
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
BasicMemoryConfig(formatter_timeout=0)
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
BasicMemoryConfig(formatter_timeout=-1)
|
||||
|
||||
def test_formatting_env_vars(self, monkeypatch):
|
||||
"""Test that formatting config can be set via environment variables."""
|
||||
monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true")
|
||||
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_COMMAND", "prettier --write {file}")
|
||||
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_TIMEOUT", "10.0")
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
assert config.format_on_save is True
|
||||
assert config.formatter_command == "prettier --write {file}"
|
||||
assert config.formatter_timeout == 10.0
|
||||
|
||||
def test_formatters_env_var_json(self, monkeypatch):
|
||||
"""Test that formatters dict can be set via JSON environment variable."""
|
||||
import json
|
||||
|
||||
formatters_json = json.dumps({"md": "prettier --write {file}", "json": "jq . {file}"})
|
||||
monkeypatch.setenv("BASIC_MEMORY_FORMATTERS", formatters_json)
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
assert config.formatters == {"md": "prettier --write {file}", "json": "jq . {file}"}
|
||||
|
||||
def test_save_and_load_formatting_config(self):
|
||||
"""Test that formatting config survives save/load cycle."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create config with formatting settings
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": str(temp_path / "main")},
|
||||
format_on_save=True,
|
||||
formatter_command="prettier --write {file}",
|
||||
formatters={"md": "prettier --write {file}", "json": "prettier --write {file}"},
|
||||
formatter_timeout=10.0,
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# Load and verify
|
||||
loaded_config = config_manager.load_config()
|
||||
assert loaded_config.format_on_save is True
|
||||
assert loaded_config.formatter_command == "prettier --write {file}"
|
||||
assert loaded_config.formatters == {
|
||||
"md": "prettier --write {file}",
|
||||
"json": "prettier --write {file}",
|
||||
}
|
||||
assert loaded_config.formatter_timeout == 10.0
|
||||
|
||||
@@ -6,16 +6,19 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
MIN_RCLONE_VERSION_EMPTY_DIRS,
|
||||
RcloneError,
|
||||
SyncProject,
|
||||
bisync_initialized,
|
||||
check_rclone_installed,
|
||||
get_project_bisync_state,
|
||||
get_project_remote,
|
||||
get_rclone_version,
|
||||
project_bisync,
|
||||
project_check,
|
||||
project_ls,
|
||||
project_sync,
|
||||
supports_create_empty_src_dirs,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,10 +197,12 @@ def test_project_sync_no_local_path(mock_is_installed):
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
|
||||
def test_project_bisync_success(mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed):
|
||||
"""Test successful project bisync."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = True # Already initialized
|
||||
mock_supports_flag.return_value = True # Mock version check
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -221,9 +226,8 @@ def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, mock_is_installed):
|
||||
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_is_installed):
|
||||
"""Test that first bisync requires --resync flag."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
@@ -238,16 +242,19 @@ def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, m
|
||||
project_bisync(project, "my-bucket")
|
||||
|
||||
assert "requires --resync" in str(exc_info.value)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_installed):
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
|
||||
def test_project_bisync_with_resync_flag(
|
||||
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
|
||||
):
|
||||
"""Test bisync with --resync flag for first time."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
mock_supports_flag.return_value = True # Mock version check
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -266,10 +273,14 @@ def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_ins
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run, mock_is_installed):
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
|
||||
def test_project_bisync_dry_run_skips_init_check(
|
||||
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
|
||||
):
|
||||
"""Test that dry-run skips initialization check."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
mock_supports_flag.return_value = True # Mock version check
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -479,3 +490,158 @@ def test_project_ls_checks_rclone_installed(mock_is_installed):
|
||||
|
||||
assert "rclone is not installed" in str(exc_info.value)
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
# Tests for rclone version detection
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_get_rclone_version_parses_standard_version(mock_run):
|
||||
"""Test parsing standard rclone version output."""
|
||||
# Clear the lru_cache before test
|
||||
get_rclone_version.cache_clear()
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="rclone v1.64.2\n- os/version: darwin 23.0.0\n- os/arch: arm64\n"
|
||||
)
|
||||
|
||||
version = get_rclone_version()
|
||||
|
||||
assert version == (1, 64, 2)
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_get_rclone_version_parses_dev_version(mock_run):
|
||||
"""Test parsing rclone dev version output like v1.60.1-DEV."""
|
||||
get_rclone_version.cache_clear()
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="rclone v1.60.1-DEV\n- os/version: linux 5.15.0\n")
|
||||
|
||||
version = get_rclone_version()
|
||||
|
||||
assert version == (1, 60, 1)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_get_rclone_version_handles_invalid_output(mock_run):
|
||||
"""Test handling of invalid rclone version output."""
|
||||
get_rclone_version.cache_clear()
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="not a valid version string")
|
||||
|
||||
version = get_rclone_version()
|
||||
|
||||
assert version is None
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_get_rclone_version_handles_exception(mock_run):
|
||||
"""Test handling of subprocess exception."""
|
||||
get_rclone_version.cache_clear()
|
||||
|
||||
mock_run.side_effect = Exception("Command failed")
|
||||
|
||||
version = get_rclone_version()
|
||||
|
||||
assert version is None
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_get_rclone_version_handles_timeout(mock_run):
|
||||
"""Test handling of subprocess timeout."""
|
||||
get_rclone_version.cache_clear()
|
||||
from subprocess import TimeoutExpired
|
||||
|
||||
mock_run.side_effect = TimeoutExpired(cmd="rclone version", timeout=10)
|
||||
|
||||
version = get_rclone_version()
|
||||
|
||||
assert version is None
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
|
||||
def test_supports_create_empty_src_dirs_true_for_new_version(mock_get_version):
|
||||
"""Test supports_create_empty_src_dirs returns True for v1.64+."""
|
||||
mock_get_version.return_value = (1, 64, 2)
|
||||
|
||||
assert supports_create_empty_src_dirs() is True
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
|
||||
def test_supports_create_empty_src_dirs_true_for_exact_min_version(mock_get_version):
|
||||
"""Test supports_create_empty_src_dirs returns True for exactly v1.64.0."""
|
||||
mock_get_version.return_value = (1, 64, 0)
|
||||
|
||||
assert supports_create_empty_src_dirs() is True
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
|
||||
def test_supports_create_empty_src_dirs_false_for_old_version(mock_get_version):
|
||||
"""Test supports_create_empty_src_dirs returns False for v1.60."""
|
||||
mock_get_version.return_value = (1, 60, 1)
|
||||
|
||||
assert supports_create_empty_src_dirs() is False
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
|
||||
def test_supports_create_empty_src_dirs_false_for_unknown_version(mock_get_version):
|
||||
"""Test supports_create_empty_src_dirs returns False when version unknown."""
|
||||
mock_get_version.return_value = None
|
||||
|
||||
assert supports_create_empty_src_dirs() is False
|
||||
|
||||
|
||||
def test_min_rclone_version_constant():
|
||||
"""Test MIN_RCLONE_VERSION_EMPTY_DIRS constant is set correctly."""
|
||||
assert MIN_RCLONE_VERSION_EMPTY_DIRS == (1, 64, 0)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
|
||||
def test_project_bisync_includes_empty_dirs_flag_when_supported(
|
||||
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
|
||||
):
|
||||
"""Test project_bisync includes --create-empty-src-dirs when supported."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = True
|
||||
mock_supports_flag.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
name="research",
|
||||
path="app/data/research",
|
||||
local_sync_path="/tmp/research",
|
||||
)
|
||||
|
||||
project_bisync(project, "my-bucket")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--create-empty-src-dirs" in cmd
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
|
||||
def test_project_bisync_excludes_empty_dirs_flag_when_not_supported(
|
||||
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
|
||||
):
|
||||
"""Test project_bisync excludes --create-empty-src-dirs for older rclone."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = True
|
||||
mock_supports_flag.return_value = False # Old rclone version
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
name="research",
|
||||
path="app/data/research",
|
||||
local_sync_path="/tmp/research",
|
||||
)
|
||||
|
||||
project_bisync(project, "my-bucket")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--create-empty-src-dirs" not in cmd
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Tests for telemetry module."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class TestGetInstallId:
|
||||
"""Tests for get_install_id function."""
|
||||
|
||||
def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch):
|
||||
"""Test that a new install ID is created on first call."""
|
||||
# Mock Path.home() to return tmp_path (works cross-platform)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
from basic_memory.telemetry import get_install_id
|
||||
|
||||
install_id = get_install_id()
|
||||
|
||||
# Should be a valid UUID format (36 chars with hyphens)
|
||||
assert len(install_id) == 36
|
||||
assert install_id.count("-") == 4
|
||||
|
||||
# File should exist
|
||||
id_file = tmp_path / ".basic-memory" / ".install_id"
|
||||
assert id_file.exists()
|
||||
assert id_file.read_text().strip() == install_id
|
||||
|
||||
def test_returns_existing_install_id(self, tmp_path, monkeypatch):
|
||||
"""Test that existing install ID is returned on subsequent calls."""
|
||||
# Mock Path.home() to return tmp_path (works cross-platform)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Create the ID file first
|
||||
id_file = tmp_path / ".basic-memory" / ".install_id"
|
||||
id_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_id = "test-uuid-12345"
|
||||
id_file.write_text(existing_id)
|
||||
|
||||
from basic_memory.telemetry import get_install_id
|
||||
|
||||
install_id = get_install_id()
|
||||
|
||||
assert install_id == existing_id
|
||||
|
||||
|
||||
class TestTelemetryConfig:
|
||||
"""Tests for telemetry configuration fields."""
|
||||
|
||||
def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch):
|
||||
"""Test that telemetry is enabled by default (Homebrew model)."""
|
||||
# Clear config cache
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
assert config.telemetry_enabled is True
|
||||
|
||||
def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch):
|
||||
"""Test that telemetry notice starts as not shown."""
|
||||
# Clear config cache
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
assert config.telemetry_notice_shown is False
|
||||
|
||||
def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch):
|
||||
"""Test that telemetry can be disabled via environment variable."""
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
|
||||
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
assert config.telemetry_enabled is False
|
||||
|
||||
|
||||
class TestTrack:
|
||||
"""Tests for the track function."""
|
||||
|
||||
def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
|
||||
"""Test that track never raises exceptions."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
# Mock OpenPanel to raise an exception
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_client.track.side_effect = Exception("Network error")
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track
|
||||
|
||||
# Should not raise
|
||||
track("test_event", {"key": "value"})
|
||||
|
||||
def test_track_respects_disabled_config(self, config_home, monkeypatch):
|
||||
"""Test that track does nothing when telemetry is disabled."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
|
||||
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track, reset_client
|
||||
|
||||
reset_client()
|
||||
track("test_event")
|
||||
|
||||
# OpenPanel should have been initialized with disabled=True
|
||||
mock_openpanel.assert_called_once()
|
||||
call_kwargs = mock_openpanel.call_args[1]
|
||||
assert call_kwargs["disabled"] is True
|
||||
|
||||
|
||||
class TestShowNoticeIfNeeded:
|
||||
"""Tests for show_notice_if_needed function."""
|
||||
|
||||
def test_shows_notice_when_enabled_and_not_shown(self, config_home, tmp_path, monkeypatch):
|
||||
"""Test that notice is shown on first run with telemetry enabled."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
# Reset state
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
# Set up config directory
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
|
||||
|
||||
# Create config with telemetry enabled but notice not shown
|
||||
from basic_memory.telemetry import show_notice_if_needed
|
||||
|
||||
with patch("rich.console.Console") as mock_console_class:
|
||||
mock_console = MagicMock()
|
||||
mock_console_class.return_value = mock_console
|
||||
|
||||
show_notice_if_needed()
|
||||
|
||||
# Console should have been called to print the notice
|
||||
mock_console.print.assert_called_once()
|
||||
|
||||
def test_does_not_show_notice_when_disabled(self, config_home, tmp_path, monkeypatch):
|
||||
"""Test that notice is not shown when telemetry is disabled."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
# Reset state
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
|
||||
|
||||
from basic_memory.telemetry import show_notice_if_needed
|
||||
|
||||
with patch("rich.console.Console") as mock_console_class:
|
||||
show_notice_if_needed()
|
||||
|
||||
# Console should not have been instantiated
|
||||
mock_console_class.assert_not_called()
|
||||
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
"""Tests for convenience tracking functions."""
|
||||
|
||||
def test_track_app_started(self, config_home, monkeypatch):
|
||||
"""Test track_app_started function."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track_app_started
|
||||
|
||||
track_app_started("cli")
|
||||
|
||||
mock_client.track.assert_called_once_with("app_started", {"mode": "cli"})
|
||||
|
||||
def test_track_mcp_tool(self, config_home, monkeypatch):
|
||||
"""Test track_mcp_tool function."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
track_mcp_tool("write_note")
|
||||
|
||||
mock_client.track.assert_called_once_with("mcp_tool_called", {"tool": "write_note"})
|
||||
|
||||
def test_track_error_truncates_message(self, config_home, monkeypatch):
|
||||
"""Test that track_error truncates long messages."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track_error
|
||||
|
||||
long_message = "x" * 500
|
||||
track_error("ValueError", long_message)
|
||||
|
||||
call_args = mock_client.track.call_args
|
||||
assert call_args[0][0] == "error"
|
||||
assert len(call_args[0][1]["message"]) == 200 # Truncated to 200 chars
|
||||
|
||||
def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
|
||||
"""Test that track_error sanitizes file paths from messages."""
|
||||
import basic_memory.config
|
||||
import basic_memory.telemetry
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.telemetry._client = None
|
||||
basic_memory.telemetry._initialized = False
|
||||
|
||||
with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel:
|
||||
mock_client = MagicMock()
|
||||
mock_openpanel.return_value = mock_client
|
||||
|
||||
from basic_memory.telemetry import track_error
|
||||
|
||||
# Test Unix path sanitization
|
||||
track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
|
||||
call_args = mock_client.track.call_args
|
||||
assert "/Users/john" not in call_args[0][1]["message"]
|
||||
assert "[FILE]" in call_args[0][1]["message"]
|
||||
|
||||
# Test Windows path sanitization
|
||||
mock_client.reset_mock()
|
||||
track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
|
||||
call_args = mock_client.track.call_args
|
||||
assert "C:\\Users\\john" not in call_args[0][1]["message"]
|
||||
assert "[FILE]" in call_args[0][1]["message"]
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Tests for file utilities."""
|
||||
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import random
|
||||
import string
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.file_utils import (
|
||||
FileError,
|
||||
FileWriteError,
|
||||
ParseError,
|
||||
compute_checksum,
|
||||
format_file,
|
||||
format_markdown_builtin,
|
||||
has_frontmatter,
|
||||
parse_frontmatter,
|
||||
remove_frontmatter,
|
||||
@@ -19,6 +23,11 @@ from basic_memory.file_utils import (
|
||||
write_file_atomic,
|
||||
)
|
||||
|
||||
# Skip marker for tests that use Unix-specific commands (cat, sh, sleep, /dev/null)
|
||||
skip_on_windows = pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="Test uses Unix-specific commands not available on Windows"
|
||||
)
|
||||
|
||||
|
||||
def get_random_word(length: int = 12, necessary_char: str | None = None) -> str:
|
||||
letters = string.ascii_lowercase
|
||||
@@ -177,7 +186,6 @@ title: Test""")
|
||||
assert "Invalid frontmatter format" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_sanitize_for_filename_removes_invalid_characters():
|
||||
# Test all invalid characters listed in the regex
|
||||
invalid_chars = '<>:"|?*'
|
||||
@@ -214,3 +222,329 @@ def test_sanitize_for_filename_removes_invalid_characters():
|
||||
)
|
||||
def test_sanitize_for_folder_edge_cases(input_folder, expected):
|
||||
assert sanitize_for_folder(input_folder) == expected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_file tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_disabled_by_default(tmp_path: Path):
|
||||
"""Test that format_file returns None when format_on_save is False (default)."""
|
||||
test_file = tmp_path / "test.md"
|
||||
test_file.write_text("# Test\n")
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
assert config.format_on_save is False
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_no_formatter_uses_builtin_for_markdown(tmp_path: Path):
|
||||
"""Test that format_file uses built-in mdformat for markdown when no external formatter configured."""
|
||||
test_file = tmp_path / "test.md"
|
||||
test_file.write_text("# Test\n")
|
||||
|
||||
# No external formatter configured - should use built-in mdformat for markdown
|
||||
config = BasicMemoryConfig(format_on_save=True, formatter_command=None)
|
||||
|
||||
result = await format_file(test_file, config, is_markdown=True)
|
||||
# mdformat should return formatted content
|
||||
assert result is not None
|
||||
assert "# Test" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_no_formatter_for_non_markdown(tmp_path: Path):
|
||||
"""Test that format_file returns None for non-markdown files when no formatter configured."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("Some text\n")
|
||||
|
||||
# No external formatter configured - should return None for non-markdown
|
||||
config = BasicMemoryConfig(format_on_save=True, formatter_command=None)
|
||||
|
||||
result = await format_file(test_file, config, is_markdown=False)
|
||||
assert result is None
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_with_global_formatter(tmp_path: Path):
|
||||
"""Test formatting with global formatter_command."""
|
||||
test_file = tmp_path / "test.md"
|
||||
original_content = "# Test\n"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
# Use a simple formatter that just echoes content (cat)
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="cat {file}", # This doesn't modify the file but runs successfully
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == original_content
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_with_extension_specific_formatter(tmp_path: Path):
|
||||
"""Test formatting with extension-specific formatter."""
|
||||
test_file = tmp_path / "test.json"
|
||||
original_content = '{"key": "value"}'
|
||||
test_file.write_text(original_content)
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="echo global", # This should NOT be used
|
||||
formatters={"json": "cat {file}"}, # Extension-specific should be used
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == original_content
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_extension_specific_overrides_global(tmp_path: Path):
|
||||
"""Test that extension-specific formatter takes precedence over global."""
|
||||
test_file = tmp_path / "test.md"
|
||||
original_content = "# Test\n"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
# Use different commands to verify which one is used
|
||||
# Since cat just reads the file, we can tell which was used by the content
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="cat /dev/null", # Would return empty
|
||||
formatters={"md": "cat {file}"}, # Should return original content
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == original_content
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_falls_back_to_global(tmp_path: Path):
|
||||
"""Test that global formatter is used when no extension-specific one exists."""
|
||||
test_file = tmp_path / "test.txt" # No extension-specific formatter for .txt
|
||||
original_content = "Some text\n"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="cat {file}",
|
||||
formatters={"md": "echo wrong"}, # Only for .md, not .txt
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == original_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_handles_nonexistent_formatter(tmp_path: Path):
|
||||
"""Test that format_file handles missing formatter executable gracefully."""
|
||||
test_file = tmp_path / "test.md"
|
||||
test_file.write_text("# Test\n")
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="nonexistent_formatter_executable_12345 {file}",
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result is None # Should return None on error
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_handles_timeout(tmp_path: Path):
|
||||
"""Test that format_file handles formatter timeout gracefully."""
|
||||
test_file = tmp_path / "test.md"
|
||||
test_file.write_text("# Test\n")
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="sleep 10", # Will timeout
|
||||
formatter_timeout=0.1, # Very short timeout
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result is None # Should return None on timeout
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_handles_nonzero_exit(tmp_path: Path):
|
||||
"""Test that format_file handles non-zero exit codes gracefully."""
|
||||
test_file = tmp_path / "test.md"
|
||||
original_content = "# Test\n"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="sh -c 'exit 1'", # Non-zero exit
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
# Should still return file content even with non-zero exit
|
||||
assert result == original_content
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_returns_modified_content(tmp_path: Path):
|
||||
"""Test that format_file returns the modified file content after formatting."""
|
||||
test_file = tmp_path / "test.md"
|
||||
original_content = "original content"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
# This formatter modifies the file to contain different content
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="sh -c 'echo modified > {file}'",
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == "modified\n"
|
||||
assert test_file.read_text() == "modified\n"
|
||||
|
||||
|
||||
@skip_on_windows
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_file_with_spaces_in_path(tmp_path: Path):
|
||||
"""Test formatting files with spaces in path."""
|
||||
subdir = tmp_path / "path with spaces"
|
||||
subdir.mkdir()
|
||||
test_file = subdir / "my file.md"
|
||||
original_content = "# Test\n"
|
||||
test_file.write_text(original_content)
|
||||
|
||||
config = BasicMemoryConfig(
|
||||
format_on_save=True,
|
||||
formatter_command="cat {file}",
|
||||
)
|
||||
|
||||
result = await format_file(test_file, config)
|
||||
assert result == original_content
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_markdown_builtin tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_markdown_builtin_formats_content(tmp_path: Path):
|
||||
"""Test that format_markdown_builtin formats markdown content."""
|
||||
test_file = tmp_path / "test.md"
|
||||
# Markdown with inconsistent formatting
|
||||
test_file.write_text("# Title\n\n*emphasis* and **bold**\n")
|
||||
|
||||
result = await format_markdown_builtin(test_file)
|
||||
|
||||
assert result is not None
|
||||
assert "# Title" in result
|
||||
assert "*emphasis*" in result or "_emphasis_" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_markdown_builtin_preserves_frontmatter(tmp_path: Path):
|
||||
"""Test that format_markdown_builtin preserves YAML frontmatter."""
|
||||
test_file = tmp_path / "test.md"
|
||||
content = """---
|
||||
title: Test Note
|
||||
tags:
|
||||
- test
|
||||
- markdown
|
||||
---
|
||||
|
||||
# Content
|
||||
|
||||
Some text here.
|
||||
"""
|
||||
test_file.write_text(content)
|
||||
|
||||
result = await format_markdown_builtin(test_file)
|
||||
|
||||
assert result is not None
|
||||
assert "---" in result
|
||||
assert "title: Test Note" in result
|
||||
assert "# Content" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_markdown_builtin_handles_gfm_tables(tmp_path: Path):
|
||||
"""Test that format_markdown_builtin handles GFM tables."""
|
||||
test_file = tmp_path / "test.md"
|
||||
content = """# Table Test
|
||||
|
||||
| Column 1 | Column 2 |
|
||||
|----------|----------|
|
||||
| A | B |
|
||||
| C | D |
|
||||
"""
|
||||
test_file.write_text(content)
|
||||
|
||||
result = await format_markdown_builtin(test_file)
|
||||
|
||||
assert result is not None
|
||||
assert "Column 1" in result
|
||||
assert "|" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_markdown_builtin_only_writes_if_changed(tmp_path: Path):
|
||||
"""Test that format_markdown_builtin only writes if content changed."""
|
||||
test_file = tmp_path / "test.md"
|
||||
# Already well-formatted content
|
||||
content = "# Title\n\nSome text.\n"
|
||||
test_file.write_text(content)
|
||||
test_file.stat().st_mtime
|
||||
|
||||
result = await format_markdown_builtin(test_file)
|
||||
|
||||
assert result is not None
|
||||
# File should not have been rewritten if content didn't change
|
||||
# (This is a best-effort check - mtime may or may not change depending on OS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BOM handling tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBOMHandling:
|
||||
"""Test handling of Byte Order Mark (BOM) in frontmatter.
|
||||
|
||||
BOM characters can be present in files created on Windows or copied
|
||||
from certain sources. They should not break frontmatter detection
|
||||
or parsing. See issue #452.
|
||||
"""
|
||||
|
||||
def test_has_frontmatter_with_bom(self):
|
||||
"""Test that has_frontmatter handles BOM correctly."""
|
||||
# Content with UTF-8 BOM
|
||||
content_with_bom = "\ufeff---\ntitle: Test\n---\nContent"
|
||||
assert has_frontmatter(content_with_bom), "Should detect frontmatter even with BOM"
|
||||
|
||||
def test_has_frontmatter_with_bom_and_windows_crlf(self):
|
||||
"""Test BOM with Windows line endings."""
|
||||
content = "\ufeff---\r\ntitle: Test\r\n---\r\nContent"
|
||||
assert has_frontmatter(content), "Should detect frontmatter with BOM and CRLF"
|
||||
|
||||
def test_parse_frontmatter_with_bom(self):
|
||||
"""Test that parse_frontmatter handles BOM correctly."""
|
||||
content_with_bom = "\ufeff---\ntitle: Test Title\ntype: note\n---\nContent"
|
||||
result = parse_frontmatter(content_with_bom)
|
||||
assert result["title"] == "Test Title"
|
||||
assert result["type"] == "note"
|
||||
|
||||
def test_remove_frontmatter_with_bom(self):
|
||||
"""Test that remove_frontmatter handles BOM correctly."""
|
||||
content_with_bom = "\ufeff---\ntitle: Test\n---\nContent here"
|
||||
result = remove_frontmatter(content_with_bom)
|
||||
assert result == "Content here"
|
||||
|
||||
@@ -129,7 +129,11 @@ dependencies = [
|
||||
{ name = "loguru" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mdformat" },
|
||||
{ name = "mdformat-frontmatter" },
|
||||
{ name = "mdformat-gfm" },
|
||||
{ name = "nest-asyncio" },
|
||||
{ name = "openpanel" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg" },
|
||||
{ name = "pybars3" },
|
||||
@@ -177,7 +181,11 @@ requires-dist = [
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "markdown-it-py", specifier = ">=3.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.2.0" },
|
||||
{ name = "mdformat", specifier = ">=0.7.22" },
|
||||
{ name = "mdformat-frontmatter", specifier = ">=2.0.8" },
|
||||
{ name = "mdformat-gfm", specifier = ">=0.3.7" },
|
||||
{ name = "nest-asyncio", specifier = ">=1.6.0" },
|
||||
{ name = "openpanel", specifier = ">=0.0.1" },
|
||||
{ name = "pillow", specifier = ">=11.1.0" },
|
||||
{ name = "psycopg", specifier = "==3.3.1" },
|
||||
{ name = "pybars3", specifier = ">=0.9.7" },
|
||||
@@ -902,14 +910,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -972,6 +980,59 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/6b/46b8bcefc2ee9e2d2e8d2bd25f1c2512f5a879fac4619d716b194d6e7ccc/mcp-1.13.0-py3-none-any.whl", hash = "sha256:8b1a002ebe6e17e894ec74d1943cc09aa9d23cb931bf58d49ab2e9fa6bb17e4b", size = 160226, upload-time = "2025-08-14T15:03:56.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdformat"
|
||||
version = "0.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdformat-frontmatter"
|
||||
version = "2.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdformat" },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "ruamel-yaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/aa/70876bec1e66f2b1ab26f31d226c09bf7b78d3d27f03c2642d1d69d1ae77/mdformat_frontmatter-2.0.8.tar.gz", hash = "sha256:c11190ae3f9c91ada78fbd820f5b221631b520484e0b644715aa0f6ed7f097ed", size = 3254, upload-time = "2023-11-07T06:53:18.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/51/b3da1292c32819c52a4e4242ad94c3c07189ca70d228b3909a58f1f3a819/mdformat_frontmatter-2.0.8-py3-none-any.whl", hash = "sha256:577396695af96ad66dff1ff781284ff3764a10be3ab8659f2ef842ab42264ebb", size = 3747, upload-time = "2023-11-07T06:53:16.972Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdformat-gfm"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mdformat" },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
@@ -1069,6 +1130,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpanel"
|
||||
version = "0.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/d2/1ca167988225113a2162fcc528ef309715f348e1ee2bcaa8405222fea08c/openpanel-0.0.1.tar.gz", hash = "sha256:96a27848d670218c03a75528b95b8e3efbd4898665d57b3ee9c81c4b3c06f922", size = 3721, upload-time = "2024-10-16T14:19:41.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/73/44ad513438c56d3e9c8dbdbea647a2f79e8be03a80967eeaa873f29a0527/openpanel-0.0.1-py3-none-any.whl", hash = "sha256:c4d5a31694d4307975bbf70bbb2fa9fae920df0fb4b8ff97fa7b78ee8fe667b2", size = 15865, upload-time = "2024-10-22T19:27:53.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -1755,6 +1828,56 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993, upload-time = "2025-08-07T08:25:23.34Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruamel-yaml"
|
||||
version = "0.18.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruamel-yaml-clib"
|
||||
version = "0.2.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.9"
|
||||
@@ -2086,6 +2209,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
|
||||
Reference in New Issue
Block a user