Compare commits

...

23 Commits

Author SHA1 Message Date
phernandez 7a49f57dee chore: update version to 0.17.1 for v0.17.1 release
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-29 10:34:06 -06:00
phernandez 58db2817d2 docs: add CHANGELOG entry for v0.17.1
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-29 10:20:00 -06:00
Paul Hernandez 98fbd60527 fix: only set BASIC_MEMORY_ENV=test during pytest runs (#482)
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-12-29 10:16:45 -06:00
phernandez 6281a81256 chore: update version to 0.17.0 for v0.17.0 release
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-28 16:06:03 -06:00
phernandez be1d0b169f style: format telemetry.py
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-28 16:05:07 -06:00
phernandez 148bf6f75a docs: add CHANGELOG entry for v0.17.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-28 16:03:13 -06:00
phernandez 272a983709 add foss as telementry source, disable analytics for tests
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-28 13:41:40 -06:00
phernandez ef7adb7b99 fix: add cloud_mode check to initialize_app()
MCP server crashes in cloud mode with:
ValueError: DATABASE_URL must be set when using Postgres backend

Root cause: initialize_app() did not check cloud_mode_enabled before
trying to initialize the database. Only ensure_initialization() had
the check. In cloud mode, tenant DBs are per-request via headers,
not via DATABASE_URL environment variable.

Also includes minor formatting fix in telemetry.py.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-28 10:31:17 -06:00
phernandez 3cd9178415 refactor: centralize test environment detection in config.is_test_env
Add is_test_env property to BasicMemoryConfig that checks:
- config.env == "test"
- BASIC_MEMORY_ENV env var is "test"
- PYTEST_CURRENT_TEST is set

Replace duplicated test detection logic in:
- api/app.py
- mcp/server.py
- services/initialization.py
- telemetry.py (disables telemetry during tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-27 12:44:42 -06:00
Paul Hernandez 856737fe3c feat: add anonymous usage telemetry (Homebrew-style opt-out) (#478)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:09:48 -06:00
Paul Hernandez 1fd680c3f1 feat: add auto-format files on save with built-in Python formatter (#474)
Signed-off-by: Cedric Hurst <cedric@spantree.net>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Cedric Hurst <cedric@spantree.net>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Sebastian B Otaegui <feniix@users.noreply.github.com>
Co-authored-by: Cedric Hurst <cedric@divideby0.io>
2025-12-24 15:39:22 -06:00
phernandez 38919d11cb docs: update CLAUDE.md with accurate CLI commands and code guidelines
- Add Code Change Guidelines section (full file read, minimize diffs, fail fast, no guessing)
- Add Literate Programming Style section (section headers, decision point comments)
- Fix CLI commands: `tools` -> `tool`, add project management commands
- Fix cloud commands to match current CLI (status, setup)
- Remove non-existent MCP tools (get_current_project, sync_status)
- Add ChatGPT-compatible tools (search, fetch)
- Remove non-existent json_canvas_spec prompt
- Add /importers to codebase architecture
- Remove unused python-developer and system-architect agents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 15:16:02 -06:00
phernandez 85684f848f fix: handle UTF-8 BOM in frontmatter parsing
Fixes #452 - Imported conversations not fully indexed

Files with UTF-8 BOM (Byte Order Mark) at the start would fail frontmatter
detection, causing:
- Title to fall back to filename instead of frontmatter value
- Permalink to be null in the database

Added strip_bom() helper function and updated all frontmatter-related
functions to strip BOM before processing:
- has_frontmatter()
- parse_frontmatter()
- remove_frontmatter()
- EntityParser.parse_markdown_content()

Added comprehensive tests for BOM handling with various scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 14:28:50 -06:00
Paul Hernandez 14ce5a3bd0 fix: handle null titles in ChatGPT import (#475)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-24 13:50:30 -06:00
phernandez 45d6caf723 fix: remove MaxLen constraint from observation content
The API Pydantic schema had a MaxLen(1000) constraint on observation
content while the database uses SQLAlchemy's Text type (unlimited).
This mismatch caused validation errors when observations exceeded
1000 characters (e.g., JSON schemas with 1458+ chars).

Removed the MaxLen constraint to match the DB schema. Retained:
- BeforeValidator(str.strip) for whitespace cleaning
- MinLen(1) to ensure non-empty content

Added comprehensive tests to verify:
- Long content (10K+ chars) is accepted
- Very long content (50K+ chars) is accepted
- Empty/whitespace-only content is still rejected
- Whitespace stripping still works

Fixes #385

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 13:41:28 -06:00
phernandez 1652f862dd fix: handle FileNotFoundError gracefully during sync
When a file exists in the database but is missing from the filesystem,
the sync worker now treats this as a deletion instead of crashing.

The sync_file() method catches FileNotFoundError specifically and calls
handle_delete() to clean up the orphaned database record. This prevents
the sync from failing on database/filesystem inconsistencies that can
occur due to race conditions, manual file deletions, or cloud storage
caching issues.

Includes a test to verify the graceful handling behavior.

Fixes #386

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 13:34:59 -06:00
phernandez c23927d124 fix: use canonical project names in API response messages
Use database-retrieved project names (new_project.name, old_project.name)
instead of input parameters (project_data.name, name) in v1 API response
messages to ensure consistent project name casing.

The v2 API already did this correctly. This fixes issue #450 where project
names would display with different casing between add and remove operations.

Fixes #450

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 13:28:06 -06:00
jope-bm 1a74d85973 feat: Complete Phase 2 of API v2 migration - Update MCP tools to use v2 endpoints (#447)
Signed-off-by: Joe P <joe@basicmemory.com>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2025-12-24 12:59:14 -06:00
phernandez d71c6e8568 fix: suppress CLI warnings for cleaner output
Suppress DeprecationWarning from aiosqlite and LogfireNotConfiguredWarning
that were cluttering CLI output.

The key fix is applying warnings.filterwarnings("ignore") AFTER all imports
in main.py, because authlib (imported via cloud commands) adds a
DeprecationWarning filter that overrides earlier suppressions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 12:21:14 -06:00
phernandez 63b98491be fix: prevent DEBUG logs from appearing on CLI stdout
Remove loguru's default handler at the very start of cli/app.py,
before any other imports. This prevents module-level code (like
TemplateLoader.__init__) from logging to stdout during import.

The import chain cli/commands/project.py -> mcp/async_client.py ->
api/app.py -> api/routers/prompt_router.py -> api/template_loader.py
triggers TemplateLoader() instantiation at DEBUG level before
init_cli_logging() can remove the default handler.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-24 11:42:25 -06:00
Paul Hernandez 622d37e4a8 fix: detect rclone version for --create-empty-src-dirs support (#473)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:27:49 -06:00
Paul Hernandez 916baf8971 fix: prevent CLI commands from hanging on exit (#471)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:27:11 -06:00
Drew Cain 95937c6d0a fix: make test-int-postgres compatible with macOS
Use gtimeout (Homebrew) or timeout (Linux), falling back to running
without timeout if neither is available. This fixes 'command not found'
errors on macOS which doesn't have GNU timeout by default.
2025-12-20 10:27:26 -06:00
86 changed files with 3585 additions and 889 deletions
-154
View File
@@ -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.
-126
View File
@@ -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.
+90
View File
@@ -1,5 +1,95 @@
# CHANGELOG
## 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
+75 -12
View File
@@ -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+)
+33
View File
@@ -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
+12 -3
View File
@@ -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
+4
View File
@@ -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 -1
View File
@@ -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.1"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+6 -2
View File
@@ -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
@@ -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":
+12 -4
View File
@@ -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,
+32 -5
View File
@@ -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
+3 -1
View File
@@ -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:
+27 -1
View File
@@ -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.
+198
View File
@@ -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()
+5 -25
View File
@@ -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":
+22 -9
View File
@@ -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:
+3 -2
View File
@@ -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]")
+7
View File
@@ -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()
+50
View File
@@ -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.
+16
View File
@@ -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
+20 -8
View File
@@ -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,
)
+196 -3
View File
@@ -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 -2
View File
@@ -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.
+1
View File
@@ -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
+72
View File
@@ -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,
)
+3 -3
View File
@@ -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,
+34 -12
View File
@@ -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:
+20 -3
View File
@@ -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:
+7 -3
View File
@@ -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())
+3 -2
View File
@@ -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,
)
+16 -10
View File
@@ -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 -3
View File
@@ -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))
+24 -12
View File
@@ -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 = {
@@ -247,11 +249,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 +275,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())
+3 -2
View File
@@ -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},
)
+28
View File
@@ -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,
+2 -1
View File
@@ -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
+33 -10
View File
@@ -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.
+1 -1
View File
@@ -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
]
+5 -1
View File
@@ -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",
+34 -1
View File
@@ -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"
)
+8 -1
View File
@@ -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)
+32 -5
View File
@@ -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)
+43 -26
View File
@@ -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")
+36 -2
View File
@@ -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)
+249
View File
@@ -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]})
+3 -1
View File
@@ -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,
)
+9
View File
@@ -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
},
)
+1 -1
View File
@@ -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
+83
View File
@@ -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
+48
View File
@@ -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()
+85
View File
@@ -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."
)
+6 -6
View File
@@ -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}"
)
+3
View File
@@ -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("!@#$%^&*()")
+8 -5
View File
@@ -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]]"
+14 -20
View File
@@ -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
+118 -87
View File
@@ -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:
+17 -37
View File
@@ -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
+16 -9
View File
@@ -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
+42
View File
@@ -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"
+2 -128
View File
@@ -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()
+62
View File
@@ -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"
+113
View File
@@ -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
+172 -6
View File
@@ -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
+276
View File
@@ -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"]
+337 -3
View File
@@ -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"
Generated
+135 -3
View File
@@ -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"