Compare commits

...

55 Commits

Author SHA1 Message Date
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
Drew Cain 24dc9a2931 chore: update version to 0.16.3 for v0.16.3 release 2025-12-20 09:53:37 -06:00
Drew Cain 85c63e5a7a docs: add CHANGELOG entry for v0.16.3 2025-12-20 09:26:00 -06:00
Drew Cain f227ef6a86 fix: Pin FastMCP to 2.12.3 to fix MCP tools visibility (#464)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 08:54:44 -06:00
Paul Hernandez 897b1edaa4 fix: Reduce watch service CPU usage by increasing reload interval (#458)
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-17 11:37:01 -06:00
Paul Hernandez 0c12a39a98 test: Add integration test for issue #416 (read_note with underscored folders) (#453)
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-17 09:56:45 -06:00
Paul Hernandez efbc758325 fix: await background sync task cancellation in lifespan shutdown (#456)
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-17 09:53:34 -06:00
Paul Hernandez a0f20eb102 chore: more Tenantless fixes (#457)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:34:05 -06:00
Paul Hernandez 78673d8e51 chore: Cloud compatibility fixes and performance improvements (#454)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:07:55 -06:00
phernandez 126c0495c0 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-12-13 15:24:43 -06:00
Paul Hernandez 4a43d7df4a remove logfire instrumentation
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-13 15:22:14 -06:00
Paul Hernandez c462faf046 Replace py-pglite with testcontainers for Postgres testing (#449)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 22:17:56 -06:00
Cedric Hurst 70bb10be1d fix: respect --project flag in background sync (fixes #434) (#436)
Signed-off-by: Cedric Hurst <cedric@spantree.net>
2025-12-08 12:58:18 -06:00
phernandez fbf9045d78 use asyncpg for just db-migrate
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-05 15:01:35 -06:00
phernandez 1094210c52 fix broken sqlite migration
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-02 20:23:55 -06:00
phernandez 391feb639f add delete cascade to entity to delete search_index (postgres only)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-12-02 10:04:21 -06:00
phernandez a920a9ff29 feat: Add project_id to Relation and Observation for efficient project-scoped queries
Denormalizes project_id onto Relation and Observation tables to enable
efficient project-scoped queries without joins. Migration backfills
from associated entity and adds pg_trgm extension with GIN indexes
for fuzzy link resolution on PostgreSQL.

🤖 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-01 21:54:56 -06:00
phernandez 05efe8701c test: Verify update() returns entity with eager-loaded relations
Add test confirming entity_repository.update() returns the entity with
observations and relations eagerly loaded, eliminating the need for a
separate find_by_id() call after update.

🤖 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-01 16:22:21 -06:00
phernandez 0eaf30bb06 remove conflict constraint name from relation_repository.py
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-30 19:31:09 -06:00
phernandez 0818bda565 feat: Add bulk insert with ON CONFLICT handling for relations
Add add_all_ignore_duplicates() method to RelationRepository for bulk
inserting relations with ON CONFLICT DO NOTHING. This handles cases
where the same [[wiki link]] appears multiple times in a document,
silently ignoring duplicates based on the (from_id, to_name, relation_type)
unique constraint.

Works with both SQLite and PostgreSQL dialects.

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

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-30 14:59:52 -06:00
phernandez 6f99d2e551 perf: lightweight permalink resolution to avoid eager loading
Add optimized repository methods for resolve_permalink() that skip
eager loading of observations and relations:

- permalink_exists(): Check existence without loading entity
- get_file_path_for_permalink(): Get only file_path column
- get_permalink_for_file_path(): Get only permalink column
- get_all_permalinks(): Get all permalinks as strings
- get_permalink_to_file_path_map(): Bulk lookup mapping
- get_file_path_to_permalink_map(): Reverse mapping

Updated entity_service.resolve_permalink() to use these lightweight
methods instead of loading full entities with all relationships.

Also added logfire instrumentation to markdown utils.

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

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-30 14:34:31 -06:00
phernandez 73d940e064 fix: observation parsing and permalink limits (#446)
1. Hashtag detection now checks for standalone words starting with #
   instead of just checking if # appears anywhere in content.
   This prevents HTML color codes like #4285F4 from being
   interpreted as hashtags.

2. Observation permalinks now truncate content to 200 chars
   to stay under PostgreSQL's btree index limit of 2704 bytes.

Added tests for both fixes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-30 00:12:04 -06:00
phernandez c3678a11d2 truncate content_stems to fix Postgres 8KB index row limit
Large documents (like ~1MB conversation imports) exceed Postgres's 8KB
index row limit, causing ProgramLimitExceededError. Truncate content_stems
to 6000 characters (with headroom for other columns) before indexing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-29 20:23:09 -06:00
phernandez 203d684c24 fix integrity error handling when setting forward relation refs
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-29 19:03:12 -06:00
phernandez a872220924 disable pooling for postgres db
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-29 13:24:47 -06:00
phernandez 7d763a66ff use entity.mtime for updated at in api
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-29 13:21:58 -06:00
phernandez b5d4fb559c fix: postgres/neon connection settings and search index dedupe
- Reduce db_pool_recycle from 3600s to 180s for Neon scale-to-zero
- Add connect_args for Neon serverless (statement cache, timeouts, app name)
- Dedupe observation permalinks in search indexing to avoid unique constraint violations
- Add tests for duplicate observation permalink handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-29 11:24:08 -06:00
phernandez 830775276d remove record_return=True from logfire spans
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-28 18:36:09 -06:00
phernandez ed894fc3ed get db pool sizes from config
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-28 16:50:57 -06:00
phernandez 704338edcf remove logfire.instrument_fastapi(app) from app.py
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-28 13:52:18 -06:00
phernandez 0ca02a7ebe add logfire instrumentation to services and repository code
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-28 12:47:29 -06:00
jope-bm 28cc5225a7 feat: Implement API v2 with ID-based endpoints (Phase 1) (#441)
Signed-off-by: Joe P <joe@basicmemory.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Claude <noreply@anthropic.com>
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-11-27 10:35:55 -06:00
phernandez 9b7bbc7116 formatting and logic change to resolve_relations, remove fuzzy search 2025-11-25 22:54:37 -06:00
phernandez 138c283d6c add postgres db type 2025-11-25 20:25:56 -06:00
phernandez 7a8954c37e add extra logic for cloud-indexing improvements 2025-11-25 13:52:58 -06:00
phernandez 10c7c19c03 fix db url for sqlite migrations
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-21 13:21:20 -06:00
159 changed files with 11335 additions and 2077 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.
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"basic-memory@basicmachines": true
}
}
+2 -16
View File
@@ -78,21 +78,7 @@ jobs:
python-version: [ "3.12", "3.13" ] python-version: [ "3.12", "3.13" ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Postgres service (only available on Linux runners) # Note: No services section needed - testcontainers handles Postgres in Docker
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: basic_memory_test
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5433:5432
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -121,7 +107,7 @@ jobs:
run: | run: |
uv pip install -e .[dev] uv pip install -e .[dev]
- name: Run tests (Postgres) - name: Run tests (Postgres via testcontainers)
run: | run: |
uv pip install pytest pytest-cov uv pip install pytest pytest-cov
just test-postgres just test-postgres
+2 -1
View File
@@ -52,4 +52,5 @@ ENV/
# claude action # claude action
claude-output claude-output
**/.claude/settings.local.json **/.claude/settings.local.json
.mcp.json
+174
View File
@@ -1,5 +1,179 @@
# CHANGELOG # CHANGELOG
## v0.17.0 (2025-12-28)
### Features
- **#478**: Add anonymous usage telemetry with Homebrew-style opt-out
([`856737f`](https://github.com/basicmachines-co/basic-memory/commit/856737f))
- Privacy-respecting anonymous usage analytics
- Easy opt-out via `BASIC_MEMORY_NO_ANALYTICS=1` environment variable
- Helps improve Basic Memory based on real usage patterns
- **#474**: Add auto-format files on save with built-in Python formatter
([`1fd680c`](https://github.com/basicmachines-co/basic-memory/commit/1fd680c))
- Automatic markdown formatting on file save
- Built-in Python formatter for consistent code style
- Configurable formatting options
- **#447**: Complete Phase 2 of API v2 migration - MCP tools use v2 endpoints
([`1a74d85`](https://github.com/basicmachines-co/basic-memory/commit/1a74d85))
- All MCP tools now use optimized v2 API endpoints
- Improved performance for knowledge graph operations
- Foundation for future API enhancements
### Bug Fixes
- Fix UTF-8 BOM handling in frontmatter parsing
([`85684f8`](https://github.com/basicmachines-co/basic-memory/commit/85684f8))
- Handles files with UTF-8 byte order marks correctly
- Prevents frontmatter parsing failures
- **#475**: Handle null titles in ChatGPT import
([`14ce5a3`](https://github.com/basicmachines-co/basic-memory/commit/14ce5a3))
- Gracefully handles conversations without titles
- Improved import robustness
- Remove MaxLen constraint from observation content
([`45d6caf`](https://github.com/basicmachines-co/basic-memory/commit/45d6caf))
- Allows longer observation content without truncation
- Removes arbitrary 2000 character limit
- Handle FileNotFoundError gracefully during sync
([`1652f86`](https://github.com/basicmachines-co/basic-memory/commit/1652f86))
- Prevents sync failures when files are deleted during sync
- More resilient file watching
- Use canonical project names in API response messages
([`c23927d`](https://github.com/basicmachines-co/basic-memory/commit/c23927d))
- Consistent project name formatting in all responses
- Suppress CLI warnings for cleaner output
([`d71c6e8`](https://github.com/basicmachines-co/basic-memory/commit/d71c6e8))
- Cleaner terminal output without spurious warnings
- Prevent DEBUG logs from appearing on CLI stdout
([`63b9849`](https://github.com/basicmachines-co/basic-memory/commit/63b9849))
- Debug logging no longer pollutes CLI output
- **#473**: Detect rclone version for --create-empty-src-dirs support
([`622d37e`](https://github.com/basicmachines-co/basic-memory/commit/622d37e))
- Automatic rclone version detection for compatibility
- Prevents errors on older rclone versions
- **#471**: Prevent CLI commands from hanging on exit
([`916baf8`](https://github.com/basicmachines-co/basic-memory/commit/916baf8))
- Fixes CLI hang on shutdown
- Proper async cleanup
- Add cloud_mode check to initialize_app()
([`ef7adb7`](https://github.com/basicmachines-co/basic-memory/commit/ef7adb7))
- Correct initialization for cloud deployments
### Internal
- Centralize test environment detection in config.is_test_env
([`3cd9178`](https://github.com/basicmachines-co/basic-memory/commit/3cd9178))
- Unified test environment detection
- Disables analytics in test environments
- Make test-int-postgres compatible with macOS
([`95937c6`](https://github.com/basicmachines-co/basic-memory/commit/95937c6))
- Cross-platform PostgreSQL testing support
## v0.16.3 (2025-12-20)
### Features
- **#439**: Add PostgreSQL database backend support
([`fb5e9e1`](https://github.com/basicmachines-co/basic-memory/commit/fb5e9e1))
- Full PostgreSQL/Neon database support as alternative to SQLite
- Async connection pooling with asyncpg
- Alembic migrations support for both backends
- Configurable via `BASIC_MEMORY_DATABASE_BACKEND` environment variable
- **#441**: Implement API v2 with ID-based endpoints (Phase 1)
([`28cc522`](https://github.com/basicmachines-co/basic-memory/commit/28cc522))
- New ID-based API endpoints for improved performance
- Foundation for future API enhancements
- Backward compatible with existing endpoints
- Add project_id to Relation and Observation for efficient project-scoped queries
([`a920a9f`](https://github.com/basicmachines-co/basic-memory/commit/a920a9f))
- Enables faster queries in multi-project environments
- Improved database schema for cloud deployments
- Add bulk insert with ON CONFLICT handling for relations
([`0818bda`](https://github.com/basicmachines-co/basic-memory/commit/0818bda))
- Faster relation creation during sync operations
- Handles duplicate relations gracefully
### Performance
- Lightweight permalink resolution to avoid eager loading
([`6f99d2e`](https://github.com/basicmachines-co/basic-memory/commit/6f99d2e))
- Reduces database queries during entity lookups
- Improved response times for read operations
### Bug Fixes
- **#464**: Pin FastMCP to 2.12.3 to fix MCP tools visibility
([`f227ef6`](https://github.com/basicmachines-co/basic-memory/commit/f227ef6))
- Fixes issue where MCP tools were not visible to Claude
- Reverts to last known working FastMCP version
- **#458**: Reduce watch service CPU usage by increasing reload interval
([`897b1ed`](https://github.com/basicmachines-co/basic-memory/commit/897b1ed))
- Lowers CPU usage during file watching
- More efficient resource utilization
- **#456**: Await background sync task cancellation in lifespan shutdown
([`efbc758`](https://github.com/basicmachines-co/basic-memory/commit/efbc758))
- Prevents hanging on shutdown
- Clean async task cleanup
- **#434**: Respect --project flag in background sync
([`70bb10b`](https://github.com/basicmachines-co/basic-memory/commit/70bb10b))
- Background sync now correctly uses specified project
- Fixes multi-project sync issues
- **#446**: Fix observation parsing and permalink limits
([`73d940e`](https://github.com/basicmachines-co/basic-memory/commit/73d940e))
- Handles edge cases in observation content
- Prevents permalink truncation issues
- **#424**: Handle periods in kebab_filenames mode
([`b004565`](https://github.com/basicmachines-co/basic-memory/commit/b004565))
- Fixes filename handling for files with multiple periods
- Improved kebab-case conversion
- Fix Postgres/Neon connection settings and search index dedupe
([`b5d4fb5`](https://github.com/basicmachines-co/basic-memory/commit/b5d4fb5))
- Optimized connection pooling for Postgres
- Prevents duplicate search index entries
### Testing & CI
- Replace py-pglite with testcontainers for Postgres testing
([`c462faf`](https://github.com/basicmachines-co/basic-memory/commit/c462faf))
- More reliable Postgres testing infrastructure
- Uses Docker-based test containers
- Add PostgreSQL testing to GitHub Actions workflow
([`66b91b2`](https://github.com/basicmachines-co/basic-memory/commit/66b91b2))
- CI now tests both SQLite and PostgreSQL backends
- Ensures cross-database compatibility
- **#416**: Add integration test for read_note with underscored folders
([`0c12a39`](https://github.com/basicmachines-co/basic-memory/commit/0c12a39))
- Verifies folder name handling edge cases
### Internal
- Cloud compatibility fixes and performance improvements (#454)
- Remove logfire instrumentation for cleaner production deployments
- Truncate content_stems to fix Postgres 8KB index row limit
## v0.16.2 (2025-11-16) ## v0.16.2 (2025-11-16)
### Bug Fixes ### Bug Fixes
+95 -18
View File
@@ -15,10 +15,14 @@ See the [README.md](README.md) file for a project overview.
### Build and Test Commands ### Build and Test Commands
- Install: `just install` or `pip install -e ".[dev]"` - Install: `just install` or `pip install -e ".[dev]"`
- Run all tests (with coverage): `just test` - Runs both unit and integration tests with unified coverage - Run all tests (SQLite + Postgres): `just test`
- Run unit tests only: `just test-unit` - Fast, no coverage - Run all tests against SQLite: `just test-sqlite`
- Run integration tests only: `just test-int` - Fast, no coverage - Run all tests against Postgres: `just test-postgres` (uses testcontainers)
- Generate HTML coverage: `just coverage` - Opens in browser - Run unit tests (SQLite): `just test-unit-sqlite`
- Run unit tests (Postgres): `just test-unit-postgres`
- Run integration tests (SQLite): `just test-int-sqlite`
- Run integration tests (Postgres): `just test-int-postgres`
- Generate HTML coverage: `just coverage`
- Single test: `pytest tests/path/to/test_file.py::test_function_name` - Single test: `pytest tests/path/to/test_file.py::test_function_name`
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"` - Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
- Lint: `just lint` or `ruff check . --fix` - Lint: `just lint` or `ruff check . --fix`
@@ -30,6 +34,8 @@ See the [README.md](README.md) file for a project overview.
**Note:** Project requires Python 3.12+ (uses type parameter syntax and `type` aliases introduced in 3.12) **Note:** Project requires Python 3.12+ (uses type parameter syntax and `type` aliases introduced in 3.12)
**Postgres Testing:** Uses [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
### Test Structure ### Test Structure
- `tests/` - Unit tests for individual components (mocked, fast) - `tests/` - Unit tests for individual components (mocked, fast)
@@ -52,11 +58,69 @@ See the [README.md](README.md) file for a project overview.
- Follow the repository pattern for data access - Follow the repository pattern for data access
- Tools communicate to api routers via the httpx ASGI client (in process) - 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 ### Codebase Architecture
- `/alembic` - Alembic db migrations - `/alembic` - Alembic db migrations
- `/api` - FastAPI implementation of REST endpoints - `/api` - FastAPI implementation of REST endpoints
- `/cli` - Typer command-line interface - `/cli` - Typer command-line interface
- `/importers` - Import functionality for Claude, ChatGPT, and other sources
- `/markdown` - Markdown parsing and processing - `/markdown` - Markdown parsing and processing
- `/mcp` - Model Context Protocol server implementation - `/mcp` - Model Context Protocol server implementation
- `/models` - SQLAlchemy ORM models - `/models` - SQLAlchemy ORM models
@@ -76,8 +140,10 @@ See the [README.md](README.md) file for a project overview.
- SQLite is used for indexing and full text search, files are source of truth - SQLite is used for indexing and full text search, files are source of truth
- Testing uses pytest with asyncio support (strict mode) - Testing uses pytest with asyncio support (strict mode)
- Unit tests (`tests/`) use mocks when necessary; integration tests (`test-int/`) use real implementations - Unit tests (`tests/`) use mocks when necessary; integration tests (`test-int/`) use real implementations
- Test database uses in-memory SQLite - By default, tests run against SQLite (fast, no Docker needed)
- Each test runs in a standalone environment with in-memory SQLite and tmp_file directory - Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required)
- Each test runs in a standalone environment with isolated database and tmp_path directory
- CI runs SQLite and Postgres tests in parallel for faster feedback
- Performance benchmarks are in `test-int/test_sync_performance_benchmark.py` - Performance benchmarks are in `test-int/test_sync_performance_benchmark.py`
- Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests - Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
@@ -132,22 +198,26 @@ See SPEC-16 for full context manager refactor details.
### Basic Memory Commands ### Basic Memory Commands
**Local 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 Claude: `basic-memory import claude conversations`
- Import from ChatGPT: `basic-memory import chatgpt` - Import from ChatGPT: `basic-memory import chatgpt`
- Import from Memory JSON: `basic-memory import memory-json` - Import from Memory JSON: `basic-memory import memory-json`
- Check sync status: `basic-memory status` - Tool access: `basic-memory tool` (provides CLI access to MCP tools)
- Tool access: `basic-memory tools` (provides CLI access to MCP tools) - Continue: `basic-memory tool continue-conversation --topic="search"`
- Guide: `basic-memory tools basic-memory-guide`
- Continue: `basic-memory tools 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):** **Cloud Commands (requires subscription):**
- Authenticate: `basic-memory cloud login` - Authenticate: `basic-memory cloud login`
- Logout: `basic-memory cloud logout` - Logout: `basic-memory cloud logout`
- Bidirectional sync: `basic-memory cloud sync` - Check cloud status: `basic-memory cloud status`
- Integrity check: `basic-memory cloud check` - Setup cloud sync: `basic-memory cloud setup`
- Mount cloud storage: `basic-memory cloud mount`
- Unmount cloud storage: `basic-memory cloud unmount`
### MCP Capabilities ### MCP Capabilities
@@ -174,18 +244,19 @@ See SPEC-16 for full context manager refactor details.
- `list_memory_projects()` - List all available projects with their status - `list_memory_projects()` - List all available projects with their status
- `create_memory_project(project_name, project_path, set_default)` - Create new Basic Memory projects - `create_memory_project(project_name, project_path, set_default)` - Create new Basic Memory projects
- `delete_project(project_name)` - Delete a project from configuration - `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:** **Visualization:**
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph 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: - MCP Prompts for better AI interaction:
- `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants - `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants
- `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context - `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context
- `search(query, after_date)` - Search with detailed, formatted results for better context understanding - `search(query, after_date)` - Search with detailed, formatted results for better context understanding
- `recent_activity(timeframe)` - View recently changed items with formatted output - `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+) ### Cloud Features (v0.15.0+)
@@ -229,6 +300,11 @@ of using AI just for code generation, we've developed a true collaborative workf
This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI
could achieve independently. could achieve independently.
**Problem-Solving Guidance:**
- If a solution isn't working after reasonable effort, suggest alternative approaches
- Don't persist with a problematic library or pattern when better alternatives exist
- Example: When py-pglite caused cascading test failures, switching to testcontainers-postgres was the right call
## GitHub Integration ## GitHub Integration
Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub: Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub:
@@ -264,5 +340,6 @@ With GitHub integration, the development workflow includes:
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history 2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
3. **Branch management** - Claude can create feature branches for implementations 3. **Branch management** - Claude can create feature branches for implementations
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves 4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets. This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
+81 -14
View File
@@ -433,42 +433,109 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project) - [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import) - [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
## Logging
Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The logging behavior varies by entry point:
| Entry Point | Default Behavior | Use Case |
|-------------|------------------|----------|
| CLI commands | File only | Prevents log output from interfering with command output |
| MCP server | File only | Stdout would corrupt the JSON-RPC protocol |
| API server | File (local) or stdout (cloud) | Docker/cloud deployments use stdout |
**Log file location:** `~/.basic-memory/basic-memory.log` (10MB rotation, 10 days retention)
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
### Examples
```bash
# Enable debug logging
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
# View logs
tail -f ~/.basic-memory/basic-memory.log
# Cloud/Docker mode (stdout logging with structured context)
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
```
## Telemetry
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
**What we collect:**
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized - no file paths or personal data)
**What we NEVER collect:**
- Note content, file names, or paths
- Personal information
- IP addresses
**Opting out:**
```bash
# Disable telemetry
basic-memory telemetry disable
# Check status
basic-memory telemetry status
# Re-enable
basic-memory telemetry enable
```
Or set the environment variable:
```bash
export BASIC_MEMORY_TELEMETRY_ENABLED=false
```
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
## Development ## Development
### Running Tests ### Running Tests
Basic Memory supports dual database backends (SQLite and Postgres). Tests are parametrized to run against both backends automatically. Basic Memory supports dual database backends (SQLite and Postgres). By default, tests run against SQLite. Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required).
**Quick Start:** **Quick Start:**
```bash ```bash
# Run SQLite tests (default, no Docker needed) # Run all tests against SQLite (default, fast)
just test-sqlite just test-sqlite
# Run Postgres tests (requires Docker) # Run all tests against Postgres (uses testcontainers)
just test-postgres just test-postgres
# Run both SQLite and Postgres tests
just test
``` ```
**Available Test Commands:** **Available Test Commands:**
- `just test-sqlite` - Run tests against SQLite only (fastest, no Docker needed) - `just test` - Run all tests against both SQLite and Postgres
- `just test-postgres` - Run tests against Postgres only (requires Docker) - `just test-sqlite` - Run all tests against SQLite (fast, no Docker needed)
- `just test-postgres` - Run all tests against Postgres (uses testcontainers)
- `just test-unit-sqlite` - Run unit tests against SQLite
- `just test-unit-postgres` - Run unit tests against Postgres
- `just test-int-sqlite` - Run integration tests against SQLite
- `just test-int-postgres` - Run integration tests against Postgres
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms) - `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
- `just test-benchmark` - Run performance benchmark tests - `just test-benchmark` - Run performance benchmark tests
- `just test-all` - Run all tests including Windows, Postgres, and benchmarks
**Postgres Testing Requirements:** **Postgres Testing:**
To run Postgres tests, you need to start the test database: Postgres tests use [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
```bash
docker-compose -f docker-compose-postgres.yml up -d
```
Tests will connect to `localhost:5433/basic_memory_test`.
**Test Markers:** **Test Markers:**
Tests use pytest markers for selective execution: Tests use pytest markers for selective execution:
- `postgres` - Tests that run against Postgres backend
- `windows` - Windows-specific database optimizations - `windows` - Windows-specific database optimizations
- `benchmark` - Performance tests (excluded from default runs) - `benchmark` - Performance tests (excluded from default runs)
+45 -29
View File
@@ -7,44 +7,60 @@ install:
@echo "" @echo ""
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate" @echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
# Run all tests with unified coverage report
test: test-unit test-int
# Run unit tests only (fast, no coverage)
test-unit:
uv run pytest -p pytest_mock -v --no-cov tests
# Run integration tests only (fast, no coverage)
test-int:
uv run pytest -p pytest_mock -v --no-cov test-int
# ============================================================================== # ==============================================================================
# DATABASE BACKEND TESTING # DATABASE BACKEND TESTING
# ============================================================================== # ==============================================================================
# Basic Memory supports dual database backends (SQLite and Postgres). # Basic Memory supports dual database backends (SQLite and Postgres).
# Tests are parametrized to run against both backends automatically. # By default, tests run against SQLite (fast, no dependencies).
# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers).
# #
# Quick Start: # Quick Start:
# just test-sqlite # Run SQLite tests (default, no Docker needed) # just test # Run all tests against SQLite (default)
# just test-postgres # Run Postgres tests (requires Docker) # just test-sqlite # Run all tests against SQLite
# just test-postgres # Run all tests against Postgres (testcontainers)
# just test-unit-sqlite # Run unit tests against SQLite
# just test-unit-postgres # Run unit tests against Postgres
# just test-int-sqlite # Run integration tests against SQLite
# just test-int-postgres # Run integration tests against Postgres
# #
# For Postgres tests, first start the database: # CI runs both in parallel for faster feedback.
# docker-compose -f docker-compose-postgres.yml up -d
# ============================================================================== # ==============================================================================
# Run tests against SQLite only (default backend, skip Postgres/Benchmark tests) # Run all tests against SQLite and Postgres
# This is the fastest option and doesn't require any Docker setup. test: test-sqlite test-postgres
# Use this for local development and quick feedback.
# Includes Windows-specific tests which will auto-skip on non-Windows platforms.
test-sqlite:
uv run pytest -p pytest_mock -v --no-cov -m "not postgres and not benchmark" tests test-int
# Run tests against Postgres only (requires docker-compose-postgres.yml up) # Run all tests against SQLite
# First start Postgres: docker-compose -f docker-compose-postgres.yml up -d test-sqlite: test-unit-sqlite test-int-sqlite
# Tests will connect to localhost:5433/basic_memory_test
# To reset the database: just postgres-reset # Run all tests against Postgres (uses testcontainers)
test-postgres: test-postgres: test-unit-postgres test-int-postgres
uv run pytest -p pytest_mock -v --no-cov -m "postgres and not benchmark" tests test-int
# Run unit tests against SQLite
test-unit-sqlite:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
# Run unit tests against Postgres
test-unit-postgres:
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
# Run integration tests against SQLite
test-int-sqlite:
uv run pytest -p pytest_mock -v --no-cov test-int
# Run integration tests against Postgres
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
# See: https://github.com/jlowin/fastmcp/issues/1311
test-int-postgres:
#!/usr/bin/env bash
set -euo pipefail
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
if [[ -n "$TIMEOUT_CMD" ]]; then
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
else
echo "⚠️ No timeout command found, running without timeout..."
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
fi
# Reset Postgres test database (drops and recreates schema) # Reset Postgres test database (drops and recreates schema)
# Useful when Alembic migration state gets out of sync during development # Useful when Alembic migration state gets out of sync during development
@@ -59,7 +75,7 @@ postgres-reset:
postgres-migrate: postgres-migrate:
@cd src/basic_memory/alembic && \ @cd src/basic_memory/alembic && \
BASIC_MEMORY_DATABASE_BACKEND=postgres \ BASIC_MEMORY_DATABASE_BACKEND=postgres \
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \ BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
uv run alembic upgrade head uv run alembic upgrade head
@echo "✅ Migrations applied to Postgres test database" @echo "✅ Migrations applied to Postgres test database"
+11 -6
View File
@@ -15,7 +15,6 @@ dependencies = [
"aiosqlite>=0.20.0", "aiosqlite>=0.20.0",
"greenlet>=3.1.1", "greenlet>=3.1.1",
"pydantic[email,timezone]>=2.10.3", "pydantic[email,timezone]>=2.10.3",
"icecream>=2.1.3",
"mcp>=1.2.0", "mcp>=1.2.0",
"pydantic-settings>=2.6.1", "pydantic-settings>=2.6.1",
"loguru>=0.7.3", "loguru>=0.7.3",
@@ -30,13 +29,19 @@ dependencies = [
"alembic>=1.14.1", "alembic>=1.14.1",
"pillow>=11.1.0", "pillow>=11.1.0",
"pybars3>=0.9.7", "pybars3>=0.9.7",
"fastmcp>=2.10.2", "fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"python-dotenv>=1.1.0", "python-dotenv>=1.1.0",
"pytest-aio>=1.9.0", "pytest-aio>=1.9.0",
"aiofiles>=24.1.0", # Async file I/O "aiofiles>=24.1.0", # Optional observability (disabled by default via config)
"logfire>=0.73.0", # Optional observability (disabled by default via config)
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
"pytest-asyncio>=1.2.0",
"psycopg==3.3.1",
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
] ]
@@ -81,8 +86,8 @@ dev = [
"pytest-xdist>=3.0.0", "pytest-xdist>=3.0.0",
"ruff>=0.1.6", "ruff>=0.1.6",
"freezegun>=1.5.5", "freezegun>=1.5.5",
"nest-asyncio>=1.6.0", "testcontainers[postgres]>=4.0.0",
"psycopg2-binary>=2.9.0", # For Alembic migrations with Postgres "psycopg>=3.2.0",
] ]
[tool.hatch.version] [tool.hatch.version]
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs""" """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation # Package version - updated by release automation
__version__ = "0.16.2" __version__ = "0.17.0"
# API version for FastAPI - independent of package version # API version for FastAPI - independent of package version
__api_version__ = "v0" __api_version__ = "v0"
+91 -25
View File
@@ -1,14 +1,25 @@
"""Alembic environment configuration.""" """Alembic environment configuration."""
import asyncio
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config # Allow nested event loops (needed for pytest-asyncio and other async contexts)
from sqlalchemy import pool # Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
try:
import nest_asyncio
nest_asyncio.apply()
except (ImportError, ValueError):
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
pass
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from alembic import context from alembic import context
from basic_memory.config import ConfigManager, DatabaseBackend from basic_memory.config import ConfigManager
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging() # set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
os.environ["BASIC_MEMORY_ENV"] = "test" os.environ["BASIC_MEMORY_ENV"] = "test"
@@ -35,12 +46,6 @@ if not current_url or current_url == "driver://user:pass@localhost/dbname":
sqlalchemy_url = DatabaseType.get_db_url( sqlalchemy_url = DatabaseType.get_db_url(
app_config.database_path, DatabaseType.FILESYSTEM, app_config app_config.database_path, DatabaseType.FILESYSTEM, app_config
) )
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
if app_config.database_backend == DatabaseBackend.POSTGRES:
# Convert asyncpg URL to psycopg2 URL for Alembic
sqlalchemy_url = sqlalchemy_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", sqlalchemy_url) config.set_main_option("sqlalchemy.url", sqlalchemy_url)
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
@@ -85,28 +90,89 @@ def run_migrations_offline() -> None:
context.run_migrations() context.run_migrations()
def do_run_migrations(connection):
"""Execute migrations with the given connection."""
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
render_as_batch=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations(connectable):
"""Run migrations asynchronously with AsyncEngine."""
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None: def run_migrations_online() -> None:
"""Run migrations in 'online' mode. """Run migrations in 'online' mode.
In this scenario we need to create an Engine Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
and associate a connection with the context.
""" """
connectable = engine_from_config( # Check if a connection/engine was provided (e.g., from run_migrations)
config.get_section(config.config_ini_section, {}), connectable = context.config.attributes.get("connection", None)
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection: if connectable is None:
context.configure( # No connection provided, create engine from config
connection=connection, url = context.config.get_main_option("sqlalchemy.url")
target_metadata=target_metadata,
include_object=include_object,
render_as_batch=True,
)
with context.begin_transaction(): # Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
context.run_migrations() if url and ("+asyncpg" in url or "+aiosqlite" in url):
# Create async engine for asyncpg or aiosqlite
connectable = create_async_engine(
url,
poolclass=pool.NullPool,
future=True,
)
else:
# Create sync engine for regular sqlite or postgresql
connectable = engine_from_config(
context.config.get_section(context.config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# Handle async engines (PostgreSQL with asyncpg)
if isinstance(connectable, AsyncEngine):
# Try to run async migrations
# nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
try:
asyncio.run(run_async_migrations(connectable))
except RuntimeError as e:
if "cannot be called from a running event loop" in str(e):
# We're in a running event loop (likely uvloop) - need to use a different approach
# Create a new thread to run the async migrations
import concurrent.futures
def run_in_thread():
"""Run async migrations in a new event loop in a separate thread."""
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
new_loop.run_until_complete(run_async_migrations(connectable))
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
future.result() # Wait for completion and re-raise any exceptions
else:
raise
else:
# Handle sync engines (SQLite) or sync connections
if hasattr(connectable, "connect"):
# It's an engine, get a connection
with connectable.connect() as connection:
do_run_migrations(connection)
else:
# It's already a connection
do_run_migrations(connectable)
if context.is_offline_mode(): if context.is_offline_mode():
@@ -0,0 +1,56 @@
"""Add cascade delete FK from search_index to entity
Revision ID: a2b3c4d5e6f7
Revises: f8a9b2c3d4e5
Create Date: 2025-12-02 07:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a2b3c4d5e6f7"
down_revision: Union[str, None] = "f8a9b2c3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add FK with CASCADE delete from search_index.entity_id to entity.id.
This migration is Postgres-only because:
- SQLite uses FTS5 virtual tables which don't support foreign keys
- The FK enables automatic cleanup of search_index entries when entities are deleted
"""
connection = op.get_bind()
dialect = connection.dialect.name
if dialect == "postgresql":
# First, clean up any orphaned search_index entries where entity no longer exists
op.execute("""
DELETE FROM search_index
WHERE entity_id IS NOT NULL
AND entity_id NOT IN (SELECT id FROM entity)
""")
# Add FK with CASCADE - nullable FK allows search_index entries without entity_id
op.create_foreign_key(
"fk_search_index_entity_id",
"search_index",
"entity",
["entity_id"],
["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
"""Remove the FK constraint."""
connection = op.get_bind()
dialect = connection.dialect.name
if dialect == "postgresql":
op.drop_constraint("fk_search_index_entity_id", "search_index", type_="foreignkey")
@@ -0,0 +1,239 @@
"""Add project_id to relation/observation and pg_trgm for fuzzy link resolution
Revision ID: f8a9b2c3d4e5
Revises: 314f1ea54dc4
Create Date: 2025-12-01 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
def column_exists(connection, table: str, column: str) -> bool:
"""Check if a column exists in a table (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.fetchone() is not None
else:
# SQLite
result = connection.execute(text(f"PRAGMA table_info({table})"))
columns = [row[1] for row in result]
return column in columns
def index_exists(connection, index_name: str) -> bool:
"""Check if an index exists (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
{"index_name": index_name},
)
return result.fetchone() is not None
else:
# SQLite
result = connection.execute(
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
{"index_name": index_name},
)
return result.fetchone() is not None
# revision identifiers, used by Alembic.
revision: str = "f8a9b2c3d4e5"
down_revision: Union[str, None] = "314f1ea54dc4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add project_id to relation and observation tables, plus pg_trgm indexes.
This migration:
1. Adds project_id column to relation and observation tables (denormalization)
2. Backfills project_id from the associated entity
3. Enables pg_trgm extension for trigram-based fuzzy matching (Postgres only)
4. Creates GIN indexes on entity title and permalink for fast similarity searches
5. Creates partial index on unresolved relations for efficient bulk resolution
"""
connection = op.get_bind()
dialect = connection.dialect.name
# -------------------------------------------------------------------------
# Add project_id to relation table
# -------------------------------------------------------------------------
# Step 1: Add project_id column as nullable first (idempotent)
if not column_exists(connection, "relation", "project_id"):
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
# Step 2: Backfill project_id from entity.project_id via from_id
if dialect == "postgresql":
op.execute("""
UPDATE relation
SET project_id = entity.project_id
FROM entity
WHERE relation.from_id = entity.id
""")
else:
# SQLite syntax
op.execute("""
UPDATE relation
SET project_id = (
SELECT entity.project_id
FROM entity
WHERE entity.id = relation.from_id
)
""")
# Step 3: Make project_id NOT NULL and add foreign key
if dialect == "postgresql":
op.alter_column("relation", "project_id", nullable=False)
op.create_foreign_key(
"fk_relation_project_id",
"relation",
"project",
["project_id"],
["id"],
)
else:
# SQLite requires batch operations for ALTER COLUMN
with op.batch_alter_table("relation") as batch_op:
batch_op.alter_column("project_id", nullable=False)
batch_op.create_foreign_key(
"fk_relation_project_id",
"project",
["project_id"],
["id"],
)
# Step 4: Create index on relation.project_id (idempotent)
if not index_exists(connection, "ix_relation_project_id"):
op.create_index("ix_relation_project_id", "relation", ["project_id"])
# -------------------------------------------------------------------------
# Add project_id to observation table
# -------------------------------------------------------------------------
# Step 1: Add project_id column as nullable first (idempotent)
if not column_exists(connection, "observation", "project_id"):
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
# Step 2: Backfill project_id from entity.project_id via entity_id
if dialect == "postgresql":
op.execute("""
UPDATE observation
SET project_id = entity.project_id
FROM entity
WHERE observation.entity_id = entity.id
""")
else:
# SQLite syntax
op.execute("""
UPDATE observation
SET project_id = (
SELECT entity.project_id
FROM entity
WHERE entity.id = observation.entity_id
)
""")
# Step 3: Make project_id NOT NULL and add foreign key
if dialect == "postgresql":
op.alter_column("observation", "project_id", nullable=False)
op.create_foreign_key(
"fk_observation_project_id",
"observation",
"project",
["project_id"],
["id"],
)
else:
# SQLite requires batch operations for ALTER COLUMN
with op.batch_alter_table("observation") as batch_op:
batch_op.alter_column("project_id", nullable=False)
batch_op.create_foreign_key(
"fk_observation_project_id",
"project",
["project_id"],
["id"],
)
# Step 4: Create index on observation.project_id (idempotent)
if not index_exists(connection, "ix_observation_project_id"):
op.create_index("ix_observation_project_id", "observation", ["project_id"])
# Postgres-specific: pg_trgm and GIN indexes
if dialect == "postgresql":
# Enable pg_trgm extension for fuzzy string matching
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# Create trigram indexes on entity table for fuzzy matching
# GIN indexes with gin_trgm_ops support similarity searches
op.execute("""
CREATE INDEX IF NOT EXISTS idx_entity_title_trgm
ON entity USING gin (title gin_trgm_ops)
""")
op.execute("""
CREATE INDEX IF NOT EXISTS idx_entity_permalink_trgm
ON entity USING gin (permalink gin_trgm_ops)
""")
# Create partial index on unresolved relations for efficient bulk resolution
# This makes "WHERE to_id IS NULL AND project_id = X" queries very fast
op.execute("""
CREATE INDEX IF NOT EXISTS idx_relation_unresolved
ON relation (project_id, to_name)
WHERE to_id IS NULL
""")
# Create index on relation.to_name for join performance in bulk resolution
op.execute("""
CREATE INDEX IF NOT EXISTS idx_relation_to_name
ON relation (to_name)
""")
def downgrade() -> None:
"""Remove project_id from relation/observation and pg_trgm indexes."""
connection = op.get_bind()
dialect = connection.dialect.name
if dialect == "postgresql":
# Drop Postgres-specific indexes
op.execute("DROP INDEX IF EXISTS idx_relation_to_name")
op.execute("DROP INDEX IF EXISTS idx_relation_unresolved")
op.execute("DROP INDEX IF EXISTS idx_entity_permalink_trgm")
op.execute("DROP INDEX IF EXISTS idx_entity_title_trgm")
# Note: We don't drop the pg_trgm extension as other code may depend on it
# Drop project_id from observation
op.drop_index("ix_observation_project_id", table_name="observation")
op.drop_constraint("fk_observation_project_id", "observation", type_="foreignkey")
op.drop_column("observation", "project_id")
# Drop project_id from relation
op.drop_index("ix_relation_project_id", table_name="relation")
op.drop_constraint("fk_relation_project_id", "relation", type_="foreignkey")
op.drop_column("relation", "project_id")
else:
# SQLite requires batch operations
op.drop_index("ix_observation_project_id", table_name="observation")
with op.batch_alter_table("observation") as batch_op:
batch_op.drop_constraint("fk_observation_project_id", type_="foreignkey")
batch_op.drop_column("project_id")
op.drop_index("ix_relation_project_id", table_name="relation")
with op.batch_alter_table("relation") as batch_op:
batch_op.drop_constraint("fk_relation_project_id", type_="foreignkey")
batch_op.drop_column("project_id")
+43 -10
View File
@@ -20,7 +20,17 @@ from basic_memory.api.routers import (
search, search,
prompt_router, prompt_router,
) )
from basic_memory.config import ConfigManager from basic_memory.api.v2.routers import (
knowledge_router as v2_knowledge,
project_router as v2_project,
memory_router as v2_memory,
search_router as v2_search,
resource_router as v2_resource,
directory_router as v2_directory,
prompt_router as v2_prompt,
importer_router as v2_importer,
)
from basic_memory.config import ConfigManager, init_api_logging
from basic_memory.services.initialization import initialize_file_sync, initialize_app from basic_memory.services.initialization import initialize_file_sync, initialize_app
@@ -28,6 +38,9 @@ from basic_memory.services.initialization import initialize_file_sync, initializ
async def lifespan(app: FastAPI): # pragma: no cover async def lifespan(app: FastAPI): # pragma: no cover
"""Lifecycle manager for the FastAPI app. Not called in stdio mcp mode""" """Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
# Initialize logging for API (stdout in cloud mode, file otherwise)
init_api_logging()
app_config = ConfigManager().config app_config = ConfigManager().config
logger.info("Starting Basic Memory API") logger.info("Starting Basic Memory API")
@@ -40,12 +53,21 @@ async def lifespan(app: FastAPI): # pragma: no cover
app.state.session_maker = session_maker app.state.session_maker = session_maker
logger.info("Database connections cached in app state") logger.info("Database connections cached in app state")
logger.info(f"Sync changes enabled: {app_config.sync_changes}") # Start file sync if enabled
if app_config.sync_changes: 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 # 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: 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 # proceed with startup
yield yield
@@ -54,6 +76,10 @@ async def lifespan(app: FastAPI): # pragma: no cover
if app.state.sync_task: if app.state.sync_task:
logger.info("Stopping sync...") logger.info("Stopping sync...")
app.state.sync_task.cancel() # pyright: ignore app.state.sync_task.cancel() # pyright: ignore
try:
await app.state.sync_task
except asyncio.CancelledError:
logger.info("Sync task cancelled successfully")
await db.shutdown_db() await db.shutdown_db()
@@ -66,8 +92,7 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Include v1 routers
# Include routers
app.include_router(knowledge.router, prefix="/{project}") app.include_router(knowledge.router, prefix="/{project}")
app.include_router(memory.router, prefix="/{project}") app.include_router(memory.router, prefix="/{project}")
app.include_router(resource.router, prefix="/{project}") app.include_router(resource.router, prefix="/{project}")
@@ -77,12 +102,20 @@ app.include_router(directory_router.router, prefix="/{project}")
app.include_router(prompt_router.router, prefix="/{project}") app.include_router(prompt_router.router, prefix="/{project}")
app.include_router(importer_router.router, prefix="/{project}") app.include_router(importer_router.router, prefix="/{project}")
# Project resource router works accross projects # Include v2 routers (ID-based paths)
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")
# Project resource router works across projects
app.include_router(project.project_resource_router) app.include_router(project.project_resource_router)
app.include_router(management.router) app.include_router(management.router)
# Auth routes are handled by FastMCP automatically when auth is enabled
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def exception_handler(request, exc): # pragma: no cover async def exception_handler(request, exc): # pragma: no cover
@@ -1,4 +1,11 @@
"""Router for knowledge graph operations.""" """Router for knowledge graph operations.
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
of path-based identifiers for improved performance and stability.
Migration guide: See docs/migration/v1-to-v2.md
"""
from typing import Annotated from typing import Annotated
@@ -25,7 +32,11 @@ from basic_memory.schemas import (
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
from basic_memory.schemas.base import Permalink, Entity from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(prefix="/knowledge", tags=["knowledge"]) router = APIRouter(
prefix="/knowledge",
tags=["knowledge"],
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
)
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None: async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
+52 -10
View File
@@ -50,6 +50,7 @@ async def get_project(
) # pragma: no cover ) # pragma: no cover
return ProjectItem( return ProjectItem(
id=found_project.id,
name=found_project.name, name=found_project.name,
path=normalize_project_path(found_project.path), path=normalize_project_path(found_project.path),
is_default=found_project.is_default or False, is_default=found_project.is_default or False,
@@ -80,9 +81,17 @@ async def update_project(
raise HTTPException(status_code=400, detail="Path must be absolute") raise HTTPException(status_code=400, detail="Path must be absolute")
# Get original project info for the response # Get original project info for the response
old_project = await project_service.get_project(name)
if not old_project:
raise HTTPException(
status_code=400, detail=f"Project '{name}' not found in configuration"
)
old_project_info = ProjectItem( old_project_info = ProjectItem(
name=name, id=old_project.id,
path=project_service.projects.get(name, ""), name=old_project.name,
path=old_project.path,
is_default=old_project.is_default or False,
) )
if path: if path:
@@ -91,14 +100,21 @@ async def update_project(
await project_service.update_project(name, is_active=is_active) await project_service.update_project(name, is_active=is_active)
# Get updated project info # Get updated project info
updated_path = path if path else project_service.projects.get(name, "") updated_project = await project_service.get_project(name)
if not updated_project:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found after update")
return ProjectStatusResponse( return ProjectStatusResponse(
message=f"Project '{name}' updated successfully", message=f"Project '{name}' updated successfully",
status="success", status="success",
default=(name == project_service.default_project), default=(name == project_service.default_project),
old_project=old_project_info, old_project=old_project_info,
new_project=ProjectItem(name=name, path=updated_path), new_project=ProjectItem(
id=updated_project.id,
name=updated_project.name,
path=updated_project.path,
is_default=updated_project.is_default or False,
),
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -186,6 +202,7 @@ async def list_projects(
project_items = [ project_items = [
ProjectItem( ProjectItem(
id=project.id,
name=project.name, name=project.name,
path=normalize_project_path(project.path), path=normalize_project_path(project.path),
is_default=project.is_default or False, is_default=project.is_default or False,
@@ -232,6 +249,7 @@ async def add_project(
status="success", status="success",
default=existing_project.is_default or False, default=existing_project.is_default or False,
new_project=ProjectItem( new_project=ProjectItem(
id=existing_project.id,
name=existing_project.name, name=existing_project.name,
path=existing_project.path, path=existing_project.path,
is_default=existing_project.is_default or False, is_default=existing_project.is_default or False,
@@ -250,12 +268,20 @@ async def add_project(
project_data.name, project_data.path, set_default=project_data.set_default project_data.name, project_data.path, set_default=project_data.set_default
) )
# Fetch the newly created project to get its ID
new_project = await project_service.get_project(project_data.name)
if not new_project:
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
return ProjectStatusResponse( # pyright: ignore [reportCallIssue] return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
message=f"Project '{project_data.name}' added successfully", message=f"Project '{new_project.name}' added successfully",
status="success", status="success",
default=project_data.set_default, default=project_data.set_default,
new_project=ProjectItem( new_project=ProjectItem(
name=project_data.name, path=project_data.path, is_default=project_data.set_default id=new_project.id,
name=new_project.name,
path=new_project.path,
is_default=new_project.is_default or False,
), ),
) )
except ValueError as e: # pragma: no cover except ValueError as e: # pragma: no cover
@@ -303,10 +329,15 @@ async def remove_project(
await project_service.remove_project(name, delete_notes=delete_notes) await project_service.remove_project(name, delete_notes=delete_notes)
return ProjectStatusResponse( return ProjectStatusResponse(
message=f"Project '{name}' removed successfully", message=f"Project '{old_project.name}' removed successfully",
status="success", status="success",
default=False, default=False,
old_project=ProjectItem(name=old_project.name, path=old_project.path), old_project=ProjectItem(
id=old_project.id,
name=old_project.name,
path=old_project.path,
is_default=old_project.is_default or False,
),
new_project=None, new_project=None,
) )
except ValueError as e: # pragma: no cover except ValueError as e: # pragma: no cover
@@ -349,8 +380,14 @@ async def set_default_project(
message=f"Project '{name}' set as default successfully", message=f"Project '{name}' set as default successfully",
status="success", status="success",
default=True, default=True,
old_project=ProjectItem(name=default_name, path=default_project.path), old_project=ProjectItem(
id=default_project.id,
name=default_name,
path=default_project.path,
is_default=False,
),
new_project=ProjectItem( new_project=ProjectItem(
id=new_default_project.id,
name=name, name=name,
path=new_default_project.path, path=new_default_project.path,
is_default=True, is_default=True,
@@ -378,7 +415,12 @@ async def get_default_project(
status_code=404, detail=f"Default Project: '{default_name}' does not exist" status_code=404, detail=f"Default Project: '{default_name}' does not exist"
) )
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True) return ProjectItem(
id=default_project.id,
name=default_project.name,
path=default_project.path,
is_default=True,
)
# Synchronize projects between config and database # Synchronize projects between config and database
+35 -25
View File
@@ -2,9 +2,9 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Union
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from loguru import logger from loguru import logger
@@ -25,6 +25,17 @@ from datetime import datetime
router = APIRouter(prefix="/resource", tags=["resources"]) router = APIRouter(prefix="/resource", tags=["resources"])
def _mtime_to_datetime(entity: EntityModel) -> datetime:
"""Convert entity mtime (file modification time) to datetime.
Returns the file's actual modification time, falling back to updated_at
if mtime is not available.
"""
if entity.mtime:
return datetime.fromtimestamp(entity.mtime).astimezone()
return entity.updated_at
def get_entity_ids(item: SearchIndexRow) -> set[int]: def get_entity_ids(item: SearchIndexRow) -> set[int]:
match item.type: match item.type:
case SearchItemType.ENTITY: case SearchItemType.ENTITY:
@@ -39,7 +50,7 @@ def get_entity_ids(item: SearchIndexRow) -> set[int]:
raise ValueError(f"Unexpected type: {item.type}") raise ValueError(f"Unexpected type: {item.type}")
@router.get("/{identifier:path}") @router.get("/{identifier:path}", response_model=None)
async def get_resource_content( async def get_resource_content(
config: ProjectConfigDep, config: ProjectConfigDep,
link_resolver: LinkResolverDep, link_resolver: LinkResolverDep,
@@ -50,7 +61,7 @@ async def get_resource_content(
identifier: str, identifier: str,
page: int = 1, page: int = 1,
page_size: int = 10, page_size: int = 10,
) -> FileResponse: ) -> Union[Response, FileResponse]:
"""Get resource content by identifier: name or permalink.""" """Get resource content by identifier: name or permalink."""
logger.debug(f"Getting content for: {identifier}") logger.debug(f"Getting content for: {identifier}")
@@ -81,13 +92,16 @@ async def get_resource_content(
# return single response # return single response
if len(results) == 1: if len(results) == 1:
entity = results[0] entity = results[0]
file_path = Path(f"{config.home}/{entity.file_path}") # Check file exists via file_service (for cloud compatibility)
if not file_path.exists(): if not await file_service.exists(entity.file_path):
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"File not found: {file_path}", detail=f"File not found: {entity.file_path}",
) )
return FileResponse(path=file_path) # Read content via file_service as bytes (works with both local and S3)
content = await file_service.read_file_bytes(entity.file_path)
content_type = file_service.content_type(entity.file_path)
return Response(content=content, media_type=content_type)
# for multiple files, initialize a temporary file for writing the results # for multiple files, initialize a temporary file for writing the results
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file: with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
@@ -97,7 +111,7 @@ async def get_resource_content(
# Read content for each entity # Read content for each entity
content = await file_service.read_entity_content(result) content = await file_service.read_entity_content(result)
memory_url = normalize_memory_url(result.permalink) memory_url = normalize_memory_url(result.permalink)
modified_date = result.updated_at.isoformat() modified_date = _mtime_to_datetime(result).isoformat()
checksum = result.checksum[:8] if result.checksum else "" checksum = result.checksum[:8] if result.checksum else ""
# Prepare the delimited content # Prepare the delimited content
@@ -171,21 +185,17 @@ async def write_resource(
else: else:
content_str = str(content) content_str = str(content)
# Get full file path # Cloud compatibility: do not assume a local filesystem path structure.
full_path = Path(f"{config.home}/{file_path}") # Delegate directory creation + writes to the configured FileService (local or S3).
await file_service.ensure_directory(Path(file_path).parent)
# Ensure parent directory exists checksum = await file_service.write_file(file_path, content_str)
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write content to file
checksum = await file_service.write_file(full_path, content_str)
# Get file info # Get file info
file_stats = file_service.file_stats(full_path) file_metadata = await file_service.get_file_metadata(file_path)
# Determine file details # Determine file details
file_name = Path(file_path).name file_name = Path(file_path).name
content_type = file_service.content_type(full_path) content_type = file_service.content_type(file_path)
entity_type = "canvas" if file_path.endswith(".canvas") else "file" entity_type = "canvas" if file_path.endswith(".canvas") else "file"
@@ -202,7 +212,7 @@ async def write_resource(
"content_type": content_type, "content_type": content_type,
"file_path": file_path, "file_path": file_path,
"checksum": checksum, "checksum": checksum,
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(), "updated_at": file_metadata.modified_at,
}, },
) )
status_code = 200 status_code = 200
@@ -214,8 +224,8 @@ async def write_resource(
content_type=content_type, content_type=content_type,
file_path=file_path, file_path=file_path,
checksum=checksum, checksum=checksum,
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(), created_at=file_metadata.created_at,
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(), updated_at=file_metadata.modified_at,
) )
entity = await entity_repository.add(entity) entity = await entity_repository.add(entity)
status_code = 201 status_code = 201
@@ -229,9 +239,9 @@ async def write_resource(
content={ content={
"file_path": file_path, "file_path": file_path,
"checksum": checksum, "checksum": checksum,
"size": file_stats.st_size, "size": file_metadata.size,
"created_at": file_stats.st_ctime, "created_at": file_metadata.created_at.timestamp(),
"modified_at": file_stats.st_mtime, "modified_at": file_metadata.modified_at.timestamp(),
}, },
) )
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
+53 -14
View File
@@ -24,11 +24,30 @@ async def to_graph_context(
page: Optional[int] = None, page: Optional[int] = None,
page_size: Optional[int] = None, page_size: Optional[int] = None,
): ):
# First pass: collect all entity IDs needed for relations
entity_ids_needed: set[int] = set()
for context_item in context_result.results:
for item in (
[context_item.primary_result] + context_item.observations + context_item.related_results
):
if item.type == SearchItemType.RELATION:
if item.from_id: # pyright: ignore
entity_ids_needed.add(item.from_id) # pyright: ignore
if item.to_id:
entity_ids_needed.add(item.to_id)
# Batch fetch all entities at once
entity_lookup: dict[int, str] = {}
if entity_ids_needed:
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
entity_lookup = {e.id: e.title for e in entities}
# Helper function to convert items to summaries # Helper function to convert items to summaries
async def to_summary(item: SearchIndexRow | ContextResultRow): def to_summary(item: SearchIndexRow | ContextResultRow):
match item.type: match item.type:
case SearchItemType.ENTITY: case SearchItemType.ENTITY:
return EntitySummary( return EntitySummary(
entity_id=item.id,
title=item.title, # pyright: ignore title=item.title, # pyright: ignore
permalink=item.permalink, permalink=item.permalink,
content=item.content, content=item.content,
@@ -37,6 +56,8 @@ async def to_graph_context(
) )
case SearchItemType.OBSERVATION: case SearchItemType.OBSERVATION:
return ObservationSummary( return ObservationSummary(
observation_id=item.id,
entity_id=item.entity_id, # pyright: ignore
title=item.title, # pyright: ignore title=item.title, # pyright: ignore
file_path=item.file_path, file_path=item.file_path,
category=item.category, # pyright: ignore category=item.category, # pyright: ignore
@@ -45,15 +66,19 @@ async def to_graph_context(
created_at=item.created_at, created_at=item.created_at,
) )
case SearchItemType.RELATION: case SearchItemType.RELATION:
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None to_title = entity_lookup.get(item.to_id) if item.to_id else None
return RelationSummary( return RelationSummary(
relation_id=item.id,
entity_id=item.entity_id, # pyright: ignore
title=item.title, # pyright: ignore title=item.title, # pyright: ignore
file_path=item.file_path, file_path=item.file_path,
permalink=item.permalink, # pyright: ignore permalink=item.permalink, # pyright: ignore
relation_type=item.relation_type, # pyright: ignore relation_type=item.relation_type, # pyright: ignore
from_entity=from_entity.title if from_entity else None, from_entity=from_title,
to_entity=to_entity.title if to_entity else None, from_entity_id=item.from_id, # pyright: ignore
to_entity=to_title,
to_entity_id=item.to_id,
created_at=item.created_at, created_at=item.created_at,
) )
case _: # pragma: no cover case _: # pragma: no cover
@@ -63,23 +88,19 @@ async def to_graph_context(
hierarchical_results = [] hierarchical_results = []
for context_item in context_result.results: for context_item in context_result.results:
# Process primary result # Process primary result
primary_result = await to_summary(context_item.primary_result) primary_result = to_summary(context_item.primary_result)
# Process observations # Process observations (always ObservationSummary, validated by context_service)
observations = [] observations = [to_summary(obs) for obs in context_item.observations]
for obs in context_item.observations:
observations.append(await to_summary(obs))
# Process related results # Process related results
related = [] related = [to_summary(rel) for rel in context_item.related_results]
for rel in context_item.related_results:
related.append(await to_summary(rel))
# Add to hierarchical results # Add to hierarchical results
hierarchical_results.append( hierarchical_results.append(
ContextResult( ContextResult(
primary_result=primary_result, primary_result=primary_result,
observations=observations, observations=observations, # pyright: ignore[reportArgumentType]
related_results=related, related_results=related,
) )
) )
@@ -111,6 +132,21 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
search_results = [] search_results = []
for r in results: for r in results:
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
# Determine which IDs to set based on type
entity_id = None
observation_id = None
relation_id = None
if r.type == SearchItemType.ENTITY:
entity_id = r.id
elif r.type == SearchItemType.OBSERVATION:
observation_id = r.id
entity_id = r.entity_id # Parent entity
elif r.type == SearchItemType.RELATION:
relation_id = r.id
entity_id = r.entity_id # Parent entity
search_results.append( search_results.append(
SearchResult( SearchResult(
title=r.title, # pyright: ignore title=r.title, # pyright: ignore
@@ -121,6 +157,9 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
content=r.content, content=r.content,
file_path=r.file_path, file_path=r.file_path,
metadata=r.metadata, metadata=r.metadata,
entity_id=entity_id,
observation_id=observation_id,
relation_id=relation_id,
category=r.category, category=r.category,
from_entity=entities[0].permalink if entities else None, from_entity=entities[0].permalink if entities else None,
to_entity=entities[1].permalink if len(entities) > 1 else None, to_entity=entities[1].permalink if len(entities) > 1 else None,
+35
View File
@@ -0,0 +1,35 @@
"""API v2 module - ID-based entity references.
Version 2 of the Basic Memory API uses integer entity IDs as the primary
identifier for improved performance and stability.
Key changes from v1:
- Entity lookups use integer IDs instead of paths/permalinks
- Direct database queries instead of cascading resolution
- Stable references that don't change with file moves
- Better caching support
All v2 routers are registered with the /v2 prefix.
"""
from basic_memory.api.v2.routers import (
knowledge_router,
memory_router,
project_router,
resource_router,
search_router,
directory_router,
prompt_router,
importer_router,
)
__all__ = [
"knowledge_router",
"memory_router",
"project_router",
"resource_router",
"search_router",
"directory_router",
"prompt_router",
"importer_router",
]
@@ -0,0 +1,21 @@
"""V2 API routers."""
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
from basic_memory.api.v2.routers.project_router import router as project_router
from basic_memory.api.v2.routers.memory_router import router as memory_router
from basic_memory.api.v2.routers.search_router import router as search_router
from basic_memory.api.v2.routers.resource_router import router as resource_router
from basic_memory.api.v2.routers.directory_router import router as directory_router
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
from basic_memory.api.v2.routers.importer_router import router as importer_router
__all__ = [
"knowledge_router",
"project_router",
"memory_router",
"search_router",
"resource_router",
"directory_router",
"prompt_router",
"importer_router",
]
@@ -0,0 +1,93 @@
"""V2 Directory Router - ID-based directory tree operations.
This router provides directory structure browsing for projects using
integer project IDs instead of name-based identifiers.
Key improvements:
- Direct project lookup via integer primary keys
- Consistent with other v2 endpoints
- Better performance through indexed queries
"""
from typing import List, Optional
from fastapi import APIRouter, Query
from basic_memory.deps import DirectoryServiceV2Dep, ProjectIdPathDep
from basic_memory.schemas.directory import DirectoryNode
router = APIRouter(prefix="/directory", tags=["directory-v2"])
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
async def get_directory_tree(
directory_service: DirectoryServiceV2Dep,
project_id: ProjectIdPathDep,
):
"""Get hierarchical directory structure from the knowledge base.
Args:
directory_service: Service for directory operations
project_id: Numeric project ID
Returns:
DirectoryNode representing the root of the hierarchical tree structure
"""
# Get a hierarchical directory tree for the specific project
tree = await directory_service.get_directory_tree()
# Return the hierarchical tree
return tree
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
async def get_directory_structure(
directory_service: DirectoryServiceV2Dep,
project_id: ProjectIdPathDep,
):
"""Get folder structure for navigation (no files).
Optimized endpoint for folder tree navigation. Returns only directory nodes
without file metadata. For full tree with files, use /directory/tree.
Args:
directory_service: Service for directory operations
project_id: Numeric project ID
Returns:
DirectoryNode tree containing only folders (type="directory")
"""
structure = await directory_service.get_directory_structure()
return structure
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
async def list_directory(
directory_service: DirectoryServiceV2Dep,
project_id: ProjectIdPathDep,
dir_name: str = Query("/", description="Directory path to list"),
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
file_name_glob: Optional[str] = Query(
None, description="Glob pattern for filtering file names"
),
):
"""List directory contents with filtering and depth control.
Args:
directory_service: Service for directory operations
project_id: Numeric project ID
dir_name: Directory path to list (default: root "/")
depth: Recursion depth (1-10, default: 1 for immediate children only)
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
Returns:
List of DirectoryNode objects matching the criteria
"""
# Get directory listing with filtering
nodes = await directory_service.list_directory(
dir_name=dir_name,
depth=depth,
file_name_glob=file_name_glob,
)
return nodes
@@ -0,0 +1,182 @@
"""V2 Import Router - ID-based data import operations.
This router uses v2 dependencies for consistent project ID handling.
Import endpoints use project_id in the path for consistency with other v2 endpoints.
"""
import json
import logging
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
from basic_memory.deps import (
ChatGPTImporterV2Dep,
ClaudeConversationsImporterV2Dep,
ClaudeProjectsImporterV2Dep,
MemoryJsonImporterV2Dep,
ProjectIdPathDep,
)
from basic_memory.importers import Importer
from basic_memory.schemas.importer import (
ChatImportResult,
EntityImportResult,
ProjectImportResult,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/import", tags=["import-v2"])
@router.post("/chatgpt", response_model=ChatImportResult)
async def import_chatgpt(
project_id: ProjectIdPathDep,
importer: ChatGPTImporterV2Dep,
file: UploadFile,
folder: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from ChatGPT JSON export.
Args:
project_id: Validated numeric project ID from URL path
file: The ChatGPT conversations.json file.
folder: The folder to place the files in.
importer: ChatGPT importer instance.
Returns:
ChatImportResult with import statistics.
Raises:
HTTPException: If import fails.
"""
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
return await import_file(importer, file, folder)
@router.post("/claude/conversations", response_model=ChatImportResult)
async def import_claude_conversations(
project_id: ProjectIdPathDep,
importer: ClaudeConversationsImporterV2Dep,
file: UploadFile,
folder: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from Claude conversations.json export.
Args:
project_id: Validated numeric project ID from URL path
file: The Claude conversations.json file.
folder: The folder to place the files in.
importer: Claude conversations importer instance.
Returns:
ChatImportResult with import statistics.
Raises:
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude conversations for project {project_id}")
return await import_file(importer, file, folder)
@router.post("/claude/projects", response_model=ProjectImportResult)
async def import_claude_projects(
project_id: ProjectIdPathDep,
importer: ClaudeProjectsImporterV2Dep,
file: UploadFile,
folder: str = Form("projects"),
) -> ProjectImportResult:
"""Import projects from Claude projects.json export.
Args:
project_id: Validated numeric project ID from URL path
file: The Claude projects.json file.
folder: The base folder to place the files in.
importer: Claude projects importer instance.
Returns:
ProjectImportResult with import statistics.
Raises:
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude projects for project {project_id}")
return await import_file(importer, file, folder)
@router.post("/memory-json", response_model=EntityImportResult)
async def import_memory_json(
project_id: ProjectIdPathDep,
importer: MemoryJsonImporterV2Dep,
file: UploadFile,
folder: str = Form("conversations"),
) -> EntityImportResult:
"""Import entities and relations from a memory.json file.
Args:
project_id: Validated numeric project ID from URL path
file: The memory.json file.
folder: Optional destination folder within the project.
importer: Memory JSON importer instance.
Returns:
EntityImportResult with import statistics.
Raises:
HTTPException: If import fails.
"""
logger.info(f"V2 Importing memory.json for project {project_id}")
try:
file_data = []
file_bytes = await file.read()
file_str = file_bytes.decode("utf-8")
for line in file_str.splitlines():
json_data = json.loads(line)
file_data.append(json_data)
result = await importer.import_data(file_data, folder)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.error_message or "Import failed",
)
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Import failed: {str(e)}",
)
return result
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
"""Helper function to import a file using an importer instance.
Args:
importer: The importer instance to use
file: The file to import
destination_folder: Destination folder for imported content
Returns:
Import result from the importer
Raises:
HTTPException: If import fails
"""
try:
# Process file
json_data = json.load(file.file)
result = await importer.import_data(json_data, destination_folder)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.error_message or "Import failed",
)
return result
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Import failed: {str(e)}",
)
@@ -0,0 +1,413 @@
"""V2 Knowledge Router - ID-based entity operations.
This router provides ID-based CRUD operations for entities, replacing the
path-based identifiers used in v1 with direct integer ID lookups.
Key improvements:
- Direct database lookups via integer primary keys
- Stable references that don't change with file moves
- Better performance through indexed queries
- Simplified caching strategies
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response
from loguru import logger
from basic_memory.deps import (
EntityServiceV2Dep,
SearchServiceV2Dep,
LinkResolverV2Dep,
ProjectConfigV2Dep,
AppConfigDep,
SyncServiceV2Dep,
EntityRepositoryV2Dep,
ProjectIdPathDep,
)
from basic_memory.schemas import DeleteEntitiesResponse
from basic_memory.schemas.base import Entity
from basic_memory.schemas.request import EditEntityRequest
from basic_memory.schemas.v2 import (
EntityResolveRequest,
EntityResolveResponse,
EntityResponseV2,
MoveEntityRequestV2,
)
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
"""Background task to resolve relations for a specific entity.
This runs asynchronously after the API response is sent, preventing
long delays when creating entities with many relations.
"""
try:
# Only resolve relations for the newly created entity
await sync_service.resolve_relations(entity_id=entity_id)
logger.debug(
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
)
except Exception as e:
# Log but don't fail - this is a background task
logger.warning(
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
)
## Resolution endpoint
@router.post("/resolve", response_model=EntityResolveResponse)
async def resolve_identifier(
project_id: ProjectIdPathDep,
data: EntityResolveRequest,
link_resolver: LinkResolverV2Dep,
) -> EntityResolveResponse:
"""Resolve a string identifier (permalink, title, or path) to an entity ID.
This endpoint provides a bridge between v1-style identifiers and v2 entity IDs.
Use this to convert existing references to the new ID-based format.
Args:
data: Request containing the identifier to resolve
Returns:
Entity ID and metadata about how it was resolved
Raises:
HTTPException: 404 if identifier cannot be resolved
Example:
POST /v2/{project}/knowledge/resolve
{"identifier": "specs/search"}
Returns:
{
"entity_id": 123,
"permalink": "specs/search",
"file_path": "specs/search.md",
"title": "Search Specification",
"resolution_method": "permalink"
}
"""
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
# Try to resolve the identifier
entity = await link_resolver.resolve_link(data.identifier)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
# Determine resolution method
resolution_method = "search" # default
if data.identifier.isdigit():
resolution_method = "id"
elif entity.permalink == data.identifier:
resolution_method = "permalink"
elif entity.title == data.identifier:
resolution_method = "title"
elif entity.file_path == data.identifier:
resolution_method = "path"
result = EntityResolveResponse(
entity_id=entity.id,
permalink=entity.permalink,
file_path=entity.file_path,
title=entity.title,
resolution_method=resolution_method,
)
logger.info(
f"API v2 response: resolved '{data.identifier}' to entity_id={result.entity_id} via {resolution_method}"
)
return result
## Read endpoints
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
async def get_entity_by_id(
project_id: ProjectIdPathDep,
entity_id: int,
entity_repository: EntityRepositoryV2Dep,
) -> EntityResponseV2:
"""Get an entity by its numeric ID.
This is the primary entity retrieval method in v2, using direct database
lookups for maximum performance.
Args:
entity_id: Numeric entity ID
Returns:
Complete entity with observations and relations
Raises:
HTTPException: 404 if entity not found
"""
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
entity = await entity_repository.get_by_id(entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
result = EntityResponseV2.model_validate(entity)
logger.info(f"API v2 response: entity_id={entity_id}, title='{result.title}'")
return result
## Create endpoints
@router.post("/entities", response_model=EntityResponseV2)
async def create_entity(
project_id: ProjectIdPathDep,
data: Entity,
background_tasks: BackgroundTasks,
entity_service: EntityServiceV2Dep,
search_service: SearchServiceV2Dep,
) -> EntityResponseV2:
"""Create a new entity.
Args:
data: Entity data to create
Returns:
Created entity with generated ID
"""
logger.info(
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
)
entity = await entity_service.create_entity(data)
# reindex
await search_service.index_entity(entity, background_tasks=background_tasks)
result = EntityResponseV2.model_validate(entity)
logger.info(
f"API v2 response: endpoint='create_entity' id={entity.id}, title={result.title}, permalink={result.permalink}, status_code=201"
)
return result
## Update endpoints
@router.put("/entities/{entity_id}", response_model=EntityResponseV2)
async def update_entity_by_id(
project_id: ProjectIdPathDep,
entity_id: int,
data: Entity,
response: Response,
background_tasks: BackgroundTasks,
entity_service: EntityServiceV2Dep,
search_service: SearchServiceV2Dep,
sync_service: SyncServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
) -> EntityResponseV2:
"""Update an entity by ID.
If the entity doesn't exist, it will be created (upsert behavior).
Args:
entity_id: Numeric entity ID
data: Updated entity data
Returns:
Updated entity
"""
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
# Check if entity exists
existing = await entity_repository.get_by_id(entity_id)
created = existing is None
# Perform update or create
entity, _ = await entity_service.create_or_update_entity(data)
response.status_code = 201 if created else 200
# reindex
await search_service.index_entity(entity, background_tasks=background_tasks)
# Schedule relation resolution for new entities
if created:
background_tasks.add_task(
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
)
result = EntityResponseV2.model_validate(entity)
logger.info(
f"API v2 response: entity_id={entity_id}, created={created}, status_code={response.status_code}"
)
return result
@router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
async def edit_entity_by_id(
project_id: ProjectIdPathDep,
entity_id: int,
data: EditEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceV2Dep,
search_service: SearchServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
) -> EntityResponseV2:
"""Edit an existing entity by ID using operations like append, prepend, etc.
Args:
entity_id: Numeric entity ID
data: Edit operation details
Returns:
Updated entity
Raises:
HTTPException: 404 if entity not found, 400 if edit fails
"""
logger.info(
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
)
# Verify entity exists
entity = await entity_repository.get_by_id(entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
try:
# Edit using the entity's permalink or path
identifier = entity.permalink or entity.file_path
updated_entity = await entity_service.edit_entity(
identifier=identifier,
operation=data.operation,
content=data.content,
section=data.section,
find_text=data.find_text,
expected_replacements=data.expected_replacements,
)
# Reindex
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
result = EntityResponseV2.model_validate(updated_entity)
logger.info(
f"API v2 response: entity_id={entity_id}, operation='{data.operation}', status_code=200"
)
return result
except Exception as e:
logger.error(f"Error editing entity {entity_id}: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Delete endpoints
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
async def delete_entity_by_id(
project_id: ProjectIdPathDep,
entity_id: int,
background_tasks: BackgroundTasks,
entity_service: EntityServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
search_service=Depends(lambda: None), # Optional for now
) -> DeleteEntitiesResponse:
"""Delete an entity by ID.
Args:
entity_id: Numeric entity ID
Returns:
Deletion status
Note: Returns deleted=False if entity doesn't exist (idempotent)
"""
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
entity = await entity_repository.get_by_id(entity_id)
if entity is None:
logger.info(f"API v2 response: entity_id={entity_id} not found, deleted=False")
return DeleteEntitiesResponse(deleted=False)
# Delete the entity
deleted = await entity_service.delete_entity(entity_id)
# Remove from search index if search service available
if search_service:
background_tasks.add_task(search_service.handle_delete, entity)
logger.info(f"API v2 response: entity_id={entity_id}, deleted={deleted}")
return DeleteEntitiesResponse(deleted=deleted)
## Move endpoint
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
async def move_entity(
project_id: ProjectIdPathDep,
entity_id: int,
data: MoveEntityRequestV2,
background_tasks: BackgroundTasks,
entity_service: EntityServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
project_config: ProjectConfigV2Dep,
app_config: AppConfigDep,
search_service: SearchServiceV2Dep,
) -> EntityResponseV2:
"""Move an entity to a new file location.
V2 API uses entity ID in the URL path for stable references.
The entity ID will remain stable after the move.
Args:
project_id: Project ID from URL path
entity_id: Entity ID from URL path (primary identifier)
data: Move request with destination path only
Returns:
Updated entity with new file path
"""
logger.info(
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
)
try:
# First, get the entity by ID to verify it exists
entity = await entity_repository.find_by_id(entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
# Move the entity using its current file path as identifier
moved_entity = await entity_service.move_entity(
identifier=entity.file_path, # Use file path for resolution
destination_path=data.destination_path,
project_config=project_config,
app_config=app_config,
)
# Reindex at new location
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
if reindexed_entity:
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
result = EntityResponseV2.model_validate(moved_entity)
logger.info(
f"API v2 response: moved entity_id={moved_entity.id} to '{data.destination_path}'"
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error moving entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,130 @@
"""V2 routes for memory:// URI operations.
This router uses integer project IDs for stable, efficient routing.
V1 uses string-based project names which are less efficient and less stable.
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Query
from loguru import logger
from basic_memory.deps import ContextServiceV2Dep, EntityRepositoryV2Dep, ProjectIdPathDep
from basic_memory.schemas.base import TimeFrame, parse_timeframe
from basic_memory.schemas.memory import (
GraphContext,
normalize_memory_url,
)
from basic_memory.schemas.search import SearchItemType
from basic_memory.api.routers.utils import to_graph_context
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
router = APIRouter(tags=["memory"])
@router.get("/memory/recent", response_model=GraphContext)
async def recent(
project_id: ProjectIdPathDep,
context_service: ContextServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
type: Annotated[list[SearchItemType] | None, Query()] = None,
depth: int = 1,
timeframe: TimeFrame = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get recent activity context for a project.
Args:
project_id: Validated numeric project ID from URL path
context_service: Context service scoped to project
entity_repository: Entity repository scoped to project
type: Types of items to include (entities, relations, observations)
depth: How many levels of related entities to include
timeframe: Time window for recent activity (e.g., "7d", "1 week")
page: Page number for pagination
page_size: Number of items per page
max_related: Maximum related entities to include per item
Returns:
GraphContext with recent activity and related entities
"""
# return all types by default
types = (
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
if not type
else type
)
logger.debug(
f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
# Parse timeframe
since = parse_timeframe(timeframe)
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
recent_context = await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
return recent_context
# get_memory_context needs to be declared last so other paths can match
@router.get("/memory/{uri:path}", response_model=GraphContext)
async def get_memory_context(
project_id: ProjectIdPathDep,
context_service: ContextServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
uri: str,
depth: int = 1,
timeframe: Optional[TimeFrame] = None,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get rich context from memory:// URI.
V2 supports both legacy path-based URIs and new ID-based URIs:
- Legacy: memory://path/to/note
- ID-based: memory://id/123 or memory://123
Args:
project_id: Validated numeric project ID from URL path
context_service: Context service scoped to project
entity_repository: Entity repository scoped to project
uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
depth: How many levels of related entities to include
timeframe: Optional time window for filtering related content
page: Page number for pagination
page_size: Number of items per page
max_related: Maximum related entities to include
Returns:
GraphContext with the entity and its related context
"""
logger.debug(
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
memory_url = normalize_memory_url(uri)
# Parse timeframe
since = parse_timeframe(timeframe) if timeframe else None
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
@@ -0,0 +1,342 @@
"""V2 Project Router - ID-based project management operations.
This router provides ID-based CRUD operations for projects, replacing the
name-based identifiers used in v1 with direct integer ID lookups.
Key improvements:
- Direct database lookups via integer primary keys
- Stable references that don't change with project renames
- Better performance through indexed queries
- Consistent with v2 entity operations
"""
import os
from typing import Optional
from fastapi import APIRouter, HTTPException, Body, Query
from loguru import logger
from basic_memory.deps import (
ProjectServiceDep,
ProjectRepositoryDep,
ProjectIdPathDep,
)
from basic_memory.schemas.project_info import (
ProjectItem,
ProjectStatusResponse,
)
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
from basic_memory.utils import normalize_project_path, generate_permalink
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
@router.post("/resolve", response_model=ProjectResolveResponse)
async def resolve_project_identifier(
data: ProjectResolveRequest,
project_repository: ProjectRepositoryDep,
) -> ProjectResolveResponse:
"""Resolve a project identifier (name or permalink) to a project ID.
This endpoint provides efficient lookup of projects by name without
needing to fetch the entire project list. Supports case-insensitive
matching on both name and permalink.
Args:
data: Request containing the identifier to resolve
Returns:
Project information including the numeric ID
Raises:
HTTPException: 404 if project not found
Example:
POST /v2/projects/resolve
{"identifier": "my-project"}
Returns:
{
"project_id": 1,
"name": "my-project",
"permalink": "my-project",
"path": "/path/to/project",
"is_active": true,
"is_default": false,
"resolution_method": "name"
}
"""
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
# Generate permalink for comparison
identifier_permalink = generate_permalink(data.identifier)
# Try to find project by ID first (if identifier is numeric)
resolution_method = "name"
project = None
if data.identifier.isdigit():
project_id = int(data.identifier)
project = await project_repository.get_by_id(project_id)
if project:
resolution_method = "id"
# If not found by ID, try by permalink first (exact match)
if not project:
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
resolution_method = "permalink"
# If not found by permalink, try case-insensitive name search
# Uses efficient database query instead of fetching all projects
if not project:
project = await project_repository.get_by_name_case_insensitive(data.identifier)
if project:
resolution_method = "name"
if not project:
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
return ProjectResolveResponse(
project_id=project.id,
name=project.name,
permalink=generate_permalink(project.name),
path=normalize_project_path(project.path),
is_active=project.is_active if hasattr(project, "is_active") else True,
is_default=project.is_default or False,
resolution_method=resolution_method,
)
@router.get("/{project_id}", response_model=ProjectItem)
async def get_project_by_id(
project_id: ProjectIdPathDep,
project_repository: ProjectRepositoryDep,
) -> ProjectItem:
"""Get project by its numeric ID.
This is the primary project retrieval method in v2, using direct database
lookups for maximum performance.
Args:
project_id: Numeric project ID
Returns:
Project information
Raises:
HTTPException: 404 if project not found
Example:
GET /v2/projects/3
"""
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
project = await project_repository.get_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
return ProjectItem(
id=project.id,
name=project.name,
path=normalize_project_path(project.path),
is_default=project.is_default or False,
)
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
async def update_project_by_id(
project_id: ProjectIdPathDep,
project_service: ProjectServiceDep,
project_repository: ProjectRepositoryDep,
path: Optional[str] = Body(None, description="New absolute path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information by ID.
Args:
project_id: Numeric project ID
path: Optional new absolute path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
Raises:
HTTPException: 400 if validation fails, 404 if project not found
Example:
PATCH /v2/projects/3
{"path": "/new/path"}
"""
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
try:
# Validate that path is absolute if provided
if path and not os.path.isabs(path):
raise HTTPException(status_code=400, detail="Path must be absolute")
# Get original project info for the response
old_project = await project_repository.get_by_id(project_id)
if not old_project:
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
old_project_info = ProjectItem(
id=old_project.id,
name=old_project.name,
path=old_project.path,
is_default=old_project.is_default or False,
)
# Update using project name (service layer still uses names internally)
if path:
await project_service.move_project(old_project.name, path)
elif is_active is not None:
await project_service.update_project(old_project.name, is_active=is_active)
# Get updated project info
updated_project = await project_repository.get_by_id(project_id)
if not updated_project:
raise HTTPException(
status_code=404, detail=f"Project with ID {project_id} not found after update"
)
return ProjectStatusResponse(
message=f"Project '{updated_project.name}' updated successfully",
status="success",
default=(old_project.name == project_service.default_project),
old_project=old_project_info,
new_project=ProjectItem(
id=updated_project.id,
name=updated_project.name,
path=updated_project.path,
is_default=updated_project.is_default or False,
),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
async def delete_project_by_id(
project_id: ProjectIdPathDep,
project_service: ProjectServiceDep,
project_repository: ProjectRepositoryDep,
delete_notes: bool = Query(
False, description="If True, delete project directory from filesystem"
),
) -> ProjectStatusResponse:
"""Delete a project by ID.
Args:
project_id: Numeric project ID
delete_notes: If True, delete the project directory from the filesystem
Returns:
Response confirming the project was deleted
Raises:
HTTPException: 400 if trying to delete default project, 404 if not found
Example:
DELETE /v2/projects/3?delete_notes=false
"""
logger.info(
f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
)
try:
old_project = await project_repository.get_by_id(project_id)
if not old_project:
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
# Check if trying to delete the default project
if old_project.name == project_service.default_project:
available_projects = await project_service.list_projects()
other_projects = [p.name for p in available_projects if p.id != project_id]
detail = f"Cannot delete default project '{old_project.name}'. "
if other_projects:
detail += (
f"Set another project as default first. Available: {', '.join(other_projects)}"
)
else:
detail += "This is the only project in your configuration."
raise HTTPException(status_code=400, detail=detail)
# Delete using project name (service layer still uses names internally)
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
return ProjectStatusResponse(
message=f"Project '{old_project.name}' removed successfully",
status="success",
default=False,
old_project=ProjectItem(
id=old_project.id,
name=old_project.name,
path=old_project.path,
is_default=old_project.is_default or False,
),
new_project=None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
async def set_default_project_by_id(
project_id: ProjectIdPathDep,
project_service: ProjectServiceDep,
project_repository: ProjectRepositoryDep,
) -> ProjectStatusResponse:
"""Set a project as the default project by ID.
Args:
project_id: Numeric project ID to set as default
Returns:
Response confirming the project was set as default
Raises:
HTTPException: 404 if project not found
Example:
PUT /v2/projects/3/default
"""
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
try:
# Get the old default project
default_name = project_service.default_project
default_project = await project_service.get_project(default_name)
if not default_project:
raise HTTPException(
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
)
# Get the new default project
new_default_project = await project_repository.get_by_id(project_id)
if not new_default_project:
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
# Set as default using project name (service layer still uses names internally)
await project_service.set_default_project(new_default_project.name)
return ProjectStatusResponse(
message=f"Project '{new_default_project.name}' set as default successfully",
status="success",
default=True,
old_project=ProjectItem(
id=default_project.id,
name=default_name,
path=default_project.path,
is_default=False,
),
new_project=ProjectItem(
id=new_default_project.id,
name=new_default_project.name,
path=new_default_project.path,
is_default=True,
),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,270 @@
"""V2 Prompt Router - ID-based prompt generation operations.
This router uses v2 dependencies for consistent project ID handling.
Prompt endpoints are action-based (not resource-based), so they don't
have entity IDs in URLs - they generate formatted prompts from queries.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, status
from loguru import logger
from basic_memory.api.routers.utils import to_graph_context, to_search_results
from basic_memory.api.template_loader import template_loader
from basic_memory.schemas.base import parse_timeframe
from basic_memory.deps import (
ContextServiceV2Dep,
EntityRepositoryV2Dep,
SearchServiceV2Dep,
EntityServiceV2Dep,
ProjectIdPathDep,
)
from basic_memory.schemas.prompt import (
ContinueConversationRequest,
SearchPromptRequest,
PromptResponse,
PromptMetadata,
)
from basic_memory.schemas.search import SearchItemType, SearchQuery
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
@router.post("/continue-conversation", response_model=PromptResponse)
async def continue_conversation(
project_id: ProjectIdPathDep,
search_service: SearchServiceV2Dep,
entity_service: EntityServiceV2Dep,
context_service: ContextServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
request: ContinueConversationRequest,
) -> PromptResponse:
"""Generate a prompt for continuing a conversation.
This endpoint takes a topic and/or timeframe and generates a prompt with
relevant context from the knowledge base.
Args:
project_id: Validated numeric project ID from URL path
request: The request parameters
Returns:
Formatted continuation prompt with context
"""
logger.info(
f"V2 Generating continue conversation prompt for project {project_id}, "
f"topic: {request.topic}, timeframe: {request.timeframe}"
)
since = parse_timeframe(request.timeframe) if request.timeframe else None
# Initialize search results
search_results = []
# Get data needed for template
if request.topic:
query = SearchQuery(text=request.topic, after_date=request.timeframe)
results = await search_service.search(query, limit=request.search_items_limit)
search_results = await to_search_results(entity_service, results)
# Build context from results
all_hierarchical_results = []
for result in search_results:
if hasattr(result, "permalink") and result.permalink:
# Get hierarchical context using the new dataclass-based approach
context_result = await context_service.build_context(
result.permalink,
depth=request.depth,
since=since,
max_related=request.related_items_limit,
include_observations=True, # Include observations for entities
)
# Process results into the schema format
graph_context = await to_graph_context(
context_result, entity_repository=entity_repository
)
# Add results to our collection (limit to top results for each permalink)
if graph_context.results:
all_hierarchical_results.extend(graph_context.results[:3])
# Limit to a reasonable number of total results
all_hierarchical_results = all_hierarchical_results[:10]
template_context = {
"topic": request.topic,
"timeframe": request.timeframe,
"hierarchical_results": all_hierarchical_results,
"has_results": len(all_hierarchical_results) > 0,
}
else:
# If no topic, get recent activity
context_result = await context_service.build_context(
types=[SearchItemType.ENTITY],
depth=request.depth,
since=since,
max_related=request.related_items_limit,
include_observations=True,
)
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
template_context = {
"topic": f"Recent Activity from ({request.timeframe})",
"timeframe": request.timeframe,
"hierarchical_results": hierarchical_results,
"has_results": len(hierarchical_results) > 0,
}
try:
# Render template
rendered_prompt = await template_loader.render(
"prompts/continue_conversation.hbs", template_context
)
# Calculate metadata
# Count items of different types
observation_count = 0
relation_count = 0
entity_count = 0
# Get the hierarchical results from the template context
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
# For topic-based search
if request.topic:
for item in hierarchical_results_for_count:
if hasattr(item, "observations"):
observation_count += len(item.observations) if item.observations else 0
if hasattr(item, "related_results"):
for related in item.related_results or []:
if hasattr(related, "type"):
if related.type == "relation":
relation_count += 1
elif related.type == "entity": # pragma: no cover
entity_count += 1 # pragma: no cover
# For recent activity
else:
for item in hierarchical_results_for_count:
if hasattr(item, "observations"):
observation_count += len(item.observations) if item.observations else 0
if hasattr(item, "related_results"):
for related in item.related_results or []:
if hasattr(related, "type"):
if related.type == "relation":
relation_count += 1
elif related.type == "entity": # pragma: no cover
entity_count += 1 # pragma: no cover
# Build metadata
metadata = {
"query": request.topic,
"timeframe": request.timeframe,
"search_count": len(search_results)
if request.topic
else 0, # Original search results count
"context_count": len(hierarchical_results_for_count),
"observation_count": observation_count,
"relation_count": relation_count,
"total_items": (
len(hierarchical_results_for_count)
+ observation_count
+ relation_count
+ entity_count
),
"search_limit": request.search_items_limit,
"context_depth": request.depth,
"related_limit": request.related_items_limit,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
prompt_metadata = PromptMetadata(**metadata)
return PromptResponse(
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
)
except Exception as e:
logger.error(f"Error rendering continue conversation template: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error rendering prompt template: {str(e)}",
)
@router.post("/search", response_model=PromptResponse)
async def search_prompt(
project_id: ProjectIdPathDep,
search_service: SearchServiceV2Dep,
entity_service: EntityServiceV2Dep,
request: SearchPromptRequest,
page: int = 1,
page_size: int = 10,
) -> PromptResponse:
"""Generate a prompt for search results.
This endpoint takes a search query and formats the results into a helpful
prompt with context and suggestions.
Args:
project_id: Validated numeric project ID from URL path
request: The search parameters
page: The page number for pagination
page_size: The number of results per page, defaults to 10
Returns:
Formatted search results prompt with context
"""
logger.info(
f"V2 Generating search prompt for project {project_id}, "
f"query: {request.query}, timeframe: {request.timeframe}"
)
limit = page_size
offset = (page - 1) * page_size
query = SearchQuery(text=request.query, after_date=request.timeframe)
results = await search_service.search(query, limit=limit, offset=offset)
search_results = await to_search_results(entity_service, results)
template_context = {
"query": request.query,
"timeframe": request.timeframe,
"results": search_results,
"has_results": len(search_results) > 0,
"result_count": len(search_results),
}
try:
# Render template
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
# Build metadata
metadata = {
"query": request.query,
"timeframe": request.timeframe,
"search_count": len(search_results),
"context_count": len(search_results),
"observation_count": 0, # Search results don't include observations
"relation_count": 0, # Search results don't include relations
"total_items": len(search_results),
"search_limit": limit,
"context_depth": 0, # No context depth for basic search
"related_limit": 0, # No related items for basic search
"generated_at": datetime.now(timezone.utc).isoformat(),
}
prompt_metadata = PromptMetadata(**metadata)
return PromptResponse(
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
)
except Exception as e:
logger.error(f"Error rendering search template: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error rendering prompt template: {str(e)}",
)
@@ -0,0 +1,286 @@
"""V2 Resource Router - ID-based resource content operations.
This router uses entity IDs for all operations, with file paths in request bodies
when needed. This is consistent with v2's ID-first design.
Key differences from v1:
- Uses integer entity IDs in URL paths instead of file paths
- File paths are in request bodies for create/update operations
- More RESTful: POST for create, PUT for update, GET for read
"""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Response
from loguru import logger
from basic_memory.deps import (
ProjectConfigV2Dep,
EntityServiceV2Dep,
FileServiceV2Dep,
EntityRepositoryV2Dep,
SearchServiceV2Dep,
ProjectIdPathDep,
)
from basic_memory.models.knowledge import Entity as EntityModel
from basic_memory.schemas.v2.resource import (
CreateResourceRequest,
UpdateResourceRequest,
ResourceResponse,
)
from basic_memory.utils import validate_project_path
router = APIRouter(prefix="/resource", tags=["resources-v2"])
@router.get("/{entity_id}")
async def get_resource_content(
project_id: ProjectIdPathDep,
entity_id: int,
config: ProjectConfigV2Dep,
entity_service: EntityServiceV2Dep,
file_service: FileServiceV2Dep,
) -> Response:
"""Get raw resource content by entity ID.
Args:
project_id: Validated numeric project ID from URL path
entity_id: Numeric entity ID
config: Project configuration
entity_service: Entity service for fetching entity data
file_service: File service for reading file content
Returns:
Response with entity content
Raises:
HTTPException: 404 if entity or file not found
"""
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
# Get entity by ID
entities = await entity_service.get_entities_by_id([entity_id])
if not entities:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
entity = entities[0]
# Validate entity file path to prevent path traversal
project_path = Path(config.home)
if not validate_project_path(entity.file_path, project_path):
logger.error(f"Invalid file path in entity {entity.id}: {entity.file_path}")
raise HTTPException(
status_code=500,
detail="Entity contains invalid file path",
)
# Check file exists via file_service (for cloud compatibility)
if not await file_service.exists(entity.file_path):
raise HTTPException(
status_code=404,
detail=f"File not found: {entity.file_path}",
)
# Read content via file_service as bytes (works with both local and S3)
content = await file_service.read_file_bytes(entity.file_path)
content_type = file_service.content_type(entity.file_path)
return Response(content=content, media_type=content_type)
@router.post("", response_model=ResourceResponse)
async def create_resource(
project_id: ProjectIdPathDep,
data: CreateResourceRequest,
config: ProjectConfigV2Dep,
file_service: FileServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
search_service: SearchServiceV2Dep,
) -> ResourceResponse:
"""Create a new resource file.
Args:
project_id: Validated numeric project ID from URL path
data: Create resource request with file_path and content
config: Project configuration
file_service: File service for writing files
entity_repository: Entity repository for creating entities
search_service: Search service for indexing
Returns:
ResourceResponse with file information including entity_id
Raises:
HTTPException: 400 for invalid file paths, 409 if file already exists
"""
try:
# Validate path to prevent path traversal attacks
project_path = Path(config.home)
if not validate_project_path(data.file_path, project_path):
logger.warning(
f"Invalid file path attempted: {data.file_path} in project {config.name}"
)
raise HTTPException(
status_code=400,
detail=f"Invalid file path: {data.file_path}. "
"Path must be relative and stay within project boundaries.",
)
# Check if entity already exists
existing_entity = await entity_repository.get_by_file_path(data.file_path)
if existing_entity:
raise HTTPException(
status_code=409,
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
f"Use PUT /resource/{existing_entity.id} to update it.",
)
# Cloud compatibility: avoid assuming a local filesystem path.
# Delegate directory creation + writes to FileService (local or S3).
await file_service.ensure_directory(Path(data.file_path).parent)
checksum = await file_service.write_file(data.file_path, data.content)
# Get file info
file_metadata = await file_service.get_file_metadata(data.file_path)
# Determine file details
file_name = Path(data.file_path).name
content_type = file_service.content_type(data.file_path)
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
# Create a new entity model
entity = EntityModel(
title=file_name,
entity_type=entity_type,
content_type=content_type,
file_path=data.file_path,
checksum=checksum,
created_at=file_metadata.created_at,
updated_at=file_metadata.modified_at,
)
entity = await entity_repository.add(entity)
# Index the file for search
await search_service.index_entity(entity) # pyright: ignore
# Return success response
return ResourceResponse(
entity_id=entity.id,
file_path=data.file_path,
checksum=checksum,
size=file_metadata.size,
created_at=file_metadata.created_at.timestamp(),
modified_at=file_metadata.modified_at.timestamp(),
)
except HTTPException:
# Re-raise HTTP exceptions without wrapping
raise
except Exception as e: # pragma: no cover
logger.error(f"Error creating resource {data.file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
@router.put("/{entity_id}", response_model=ResourceResponse)
async def update_resource(
project_id: ProjectIdPathDep,
entity_id: int,
data: UpdateResourceRequest,
config: ProjectConfigV2Dep,
file_service: FileServiceV2Dep,
entity_repository: EntityRepositoryV2Dep,
search_service: SearchServiceV2Dep,
) -> ResourceResponse:
"""Update an existing resource by entity ID.
Can update content and optionally move the file to a new path.
Args:
project_id: Validated numeric project ID from URL path
entity_id: Entity ID of the resource to update
data: Update resource request with content and optional new file_path
config: Project configuration
file_service: File service for writing files
entity_repository: Entity repository for updating entities
search_service: Search service for indexing
Returns:
ResourceResponse with updated file information
Raises:
HTTPException: 404 if entity not found, 400 for invalid paths
"""
try:
# Get existing entity
entity = await entity_repository.get_by_id(entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
# Determine target file path
target_file_path = data.file_path if data.file_path else entity.file_path
# Validate path to prevent path traversal attacks
project_path = Path(config.home)
if not validate_project_path(target_file_path, project_path):
logger.warning(
f"Invalid file path attempted: {target_file_path} in project {config.name}"
)
raise HTTPException(
status_code=400,
detail=f"Invalid file path: {target_file_path}. "
"Path must be relative and stay within project boundaries.",
)
# If moving file, handle the move
if data.file_path and data.file_path != entity.file_path:
# Ensure new parent directory exists (no-op for S3)
await file_service.ensure_directory(Path(target_file_path).parent)
# If old file exists, remove it via file_service (for cloud compatibility)
if await file_service.exists(entity.file_path):
await file_service.delete_file(entity.file_path)
else:
# Ensure directory exists for in-place update
await file_service.ensure_directory(Path(target_file_path).parent)
# Write content to target file
checksum = await file_service.write_file(target_file_path, data.content)
# Get file info
file_metadata = await file_service.get_file_metadata(target_file_path)
# Determine file details
file_name = Path(target_file_path).name
content_type = file_service.content_type(target_file_path)
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
# Update entity
updated_entity = await entity_repository.update(
entity_id,
{
"title": file_name,
"entity_type": entity_type,
"content_type": content_type,
"file_path": target_file_path,
"checksum": checksum,
"updated_at": file_metadata.modified_at,
},
)
# Index the updated file for search
await search_service.index_entity(updated_entity) # pyright: ignore
# Return success response
return ResourceResponse(
entity_id=entity_id,
file_path=target_file_path,
checksum=checksum,
size=file_metadata.size,
created_at=file_metadata.created_at.timestamp(),
modified_at=file_metadata.modified_at.timestamp(),
)
except HTTPException:
# Re-raise HTTP exceptions without wrapping
raise
except Exception as e: # pragma: no cover
logger.error(f"Error updating resource {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
@@ -0,0 +1,73 @@
"""V2 router for search operations.
This router uses integer project IDs for stable, efficient routing.
V1 uses string-based project names which are less efficient and less stable.
"""
from fastapi import APIRouter, BackgroundTasks
from basic_memory.api.routers.utils import to_search_results
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.deps import SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
router = APIRouter(tags=["search"])
@router.post("/search/", response_model=SearchResponse)
async def search(
project_id: ProjectIdPathDep,
query: SearchQuery,
search_service: SearchServiceV2Dep,
entity_service: EntityServiceV2Dep,
page: int = 1,
page_size: int = 10,
):
"""Search across all knowledge and documents in a project.
V2 uses integer project IDs for improved performance and stability.
Args:
project_id: Validated numeric project ID from URL path
query: Search query parameters (text, filters, etc.)
search_service: Search service scoped to project
entity_service: Entity service scoped to project
page: Page number for pagination
page_size: Number of results per page
Returns:
SearchResponse with paginated search results
"""
limit = page_size
offset = (page - 1) * page_size
results = await search_service.search(query, limit=limit, offset=offset)
search_results = await to_search_results(entity_service, results)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
)
@router.post("/search/reindex")
async def reindex(
project_id: ProjectIdPathDep,
background_tasks: BackgroundTasks,
search_service: SearchServiceV2Dep,
):
"""Recreate and populate the search index for a project.
This is a background operation that rebuilds the search index
from scratch. Useful after bulk updates or if the index becomes
corrupted.
Args:
project_id: Validated numeric project ID from URL path
background_tasks: FastAPI background tasks handler
search_service: Search service scoped to project
Returns:
Status message indicating reindex has been initiated
"""
await search_service.reindex_all(background_tasks=background_tasks)
return {"status": "ok", "message": "Reindex initiated"}
+35 -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 # 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: def version_callback(value: bool) -> None:
@@ -31,8 +44,25 @@ def app_callback(
) -> None: ) -> None:
"""Basic Memory - Local-first personal knowledge management.""" """Basic Memory - Local-first personal knowledge management."""
# Run initialization for every command unless --version was specified # Initialize logging for CLI (file only, no stdout)
if not version and ctx.invoked_subcommand is not None: init_cli_logging()
# Show telemetry notice and track CLI startup
# Skip for 'mcp' command - it handles its own telemetry in lifespan
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
show_notice_if_needed()
track_app_started("cli")
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
api_commands = {"mcp", "status", "sync", "project", "tool"}
if (
not version
and ctx.invoked_subcommand is not None
and ctx.invoked_subcommand not in api_commands
):
from basic_memory.services.initialization import ensure_initialization from basic_memory.services.initialization import ensure_initialization
app_config = ConfigManager().config app_config = ConfigManager().config
+3 -1
View File
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory.""" """CLI commands for basic-memory."""
from . import status, db, import_memory_json, mcp, import_claude_conversations 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__ = [ __all__ = [
"status", "status",
@@ -13,4 +13,6 @@ __all__ = [
"import_chatgpt", "import_chatgpt",
"tool", "tool",
"project", "project",
"format",
"telemetry",
] ]
@@ -9,11 +9,14 @@ This module provides simplified, project-scoped rclone operations:
Replaces tenant-wide sync with project-scoped workflows. Replaces tenant-wide sync with project-scoped workflows.
""" """
import re
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from loguru import logger
from rich.console import Console from rich.console import Console
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed 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() console = Console()
# Minimum rclone version for --create-empty-src-dirs support
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
class RcloneError(Exception): class RcloneError(Exception):
"""Exception raised for rclone command errors.""" """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 @dataclass
class SyncProject: class SyncProject:
"""Project configured for cloud sync. """Project configured for cloud sync.
@@ -218,7 +260,6 @@ def project_bisync(
"bisync", "bisync",
str(local_path), str(local_path),
remote_path, remote_path,
"--create-empty-src-dirs",
"--resilient", "--resilient",
"--conflict-resolve=newer", "--conflict-resolve=newer",
"--max-delete=25", "--max-delete=25",
@@ -229,6 +270,10 @@ def project_bisync(
str(state_path), 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: if verbose:
cmd.append("--verbose") cmd.append("--verbose")
else: else:
+27 -1
View File
@@ -1,12 +1,14 @@
"""utility functions for commands""" """utility functions for commands"""
from typing import Optional import asyncio
from typing import Optional, TypeVar, Coroutine, Any
from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.exceptions import ToolError
import typer import typer
from rich.console import Console from rich.console import Console
from basic_memory import db
from basic_memory.mcp.async_client import get_client from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post, call_get from basic_memory.mcp.tools.utils import call_post, call_get
@@ -15,6 +17,30 @@ from basic_memory.schemas import ProjectInfoResponse
console = Console() 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): async def run_sync(project: Optional[str] = None, force_full: bool = False):
"""Run sync operation via API endpoint. """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 import typer
from basic_memory.cli.app import import_app 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.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger from loguru import logger
@@ -20,8 +20,9 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor: async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance.""" """Get MarkdownProcessor instance."""
config = get_project_config() config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home) 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.") @import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
@@ -7,7 +7,7 @@ from typing import Annotated
import typer import typer
from basic_memory.cli.app import claude_app 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.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger from loguru import logger
@@ -20,8 +20,9 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor: async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance.""" """Get MarkdownProcessor instance."""
config = get_project_config() config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home) 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.") @claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
@@ -7,7 +7,7 @@ from typing import Annotated
import typer import typer
from basic_memory.cli.app import claude_app 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.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger from loguru import logger
@@ -20,8 +20,9 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor: async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance.""" """Get MarkdownProcessor instance."""
config = get_project_config() config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home) 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.") @claude_app.command(name="projects", help="Import projects from Claude.ai.")
@@ -7,7 +7,7 @@ from typing import Annotated
import typer import typer
from basic_memory.cli.app import import_app 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.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger from loguru import logger
@@ -20,8 +20,9 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor: async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance.""" """Get MarkdownProcessor instance."""
config = get_project_config() config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home) entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser) return MarkdownProcessor(entity_parser, app_config=app_config)
@import_app.command() @import_app.command()
+8 -26
View File
@@ -1,14 +1,13 @@
"""MCP server command with streamable HTTP transport.""" """MCP server command with streamable HTTP transport."""
import asyncio
import os import os
import typer import typer
from typing import Optional from typing import Optional
from basic_memory.cli.app import app from basic_memory.cli.app import app
from basic_memory.config import ConfigManager from basic_memory.config import ConfigManager, init_mcp_logging
# Import mcp instance # Import mcp instance (has lifespan that handles initialization and file sync)
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
# Import mcp tools to register them # 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 prompts to register them
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
from loguru import logger from loguru import logger
import threading
from basic_memory.services.initialization import initialize_file_sync
config = ConfigManager().config config = ConfigManager().config
@@ -43,7 +40,11 @@ if not config.cloud_mode_enabled:
- stdio: Standard I/O (good for local usage) - stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default) - streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients) - sse: Server-Sent Events (for compatibility with existing clients)
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
""" """
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
# Validate and set project constraint if specified # Validate and set project constraint if specified
if project: if project:
@@ -57,27 +58,8 @@ if not config.cloud_mode_enabled:
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}") logger.info(f"MCP server constrained to project: {project_name}")
app_config = ConfigManager().config # Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
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)
logger.info(f"Starting MCP server with {transport.upper()} transport") logger.info(f"Starting MCP server with {transport.upper()} transport")
if transport == "stdio": if transport == "stdio":
+22 -9
View File
@@ -16,14 +16,9 @@ from datetime import datetime
from rich.panel import Panel from rich.panel import Panel
from basic_memory.mcp.async_client import get_client 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, call_post, call_delete, call_put, call_patch
from basic_memory.schemas.project_info import ProjectList from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
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.utils import generate_permalink, normalize_project_path 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 # Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import ( from basic_memory.cli.commands.cloud.rclone_commands import (
@@ -254,9 +249,17 @@ def remove_project(
async def _remove_project(): async def _remove_project():
async with get_client() as client: async with get_client() as client:
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name) 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( 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()) return ProjectStatusResponse.model_validate(response.json())
@@ -329,8 +332,18 @@ def set_default_project(
async def _set_default(): async def _set_default():
async with get_client() as client: async with get_client() as client:
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name) 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()) return ProjectStatusResponse.model_validate(response.json())
try: try:
+3 -2
View File
@@ -1,6 +1,5 @@
"""Status command for basic-memory CLI.""" """Status command for basic-memory CLI."""
import asyncio
from typing import Set, Dict from typing import Set, Dict
from typing import Annotated, Optional from typing import Annotated, Optional
@@ -165,8 +164,10 @@ def status(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
): ):
"""Show sync status between files and database.""" """Show sync status between files and database."""
from basic_memory.cli.commands.command_utils import run_with_cleanup
try: try:
asyncio.run(run_status(project, verbose)) # pragma: no cover run_with_cleanup(run_status(project, verbose)) # pragma: no cover
except Exception as e: except Exception as e:
logger.error(f"Error checking status: {e}") logger.error(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True) 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, mcp,
project, project,
status, status,
telemetry,
tool, 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 if __name__ == "__main__": # pragma: no cover
# start the app # start the app
app() app()
+149 -71
View File
@@ -9,10 +9,9 @@ from typing import Any, Dict, Literal, Optional, List, Tuple
from enum import Enum from enum import Enum
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
import basic_memory
from basic_memory.utils import setup_logging, generate_permalink from basic_memory.utils import setup_logging, generate_permalink
@@ -100,13 +99,32 @@ class BasicMemoryConfig(BaseSettings):
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.", description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
) )
# Database connection pool configuration (Postgres only)
db_pool_size: int = Field(
default=20,
description="Number of connections to keep in the pool (Postgres only)",
gt=0,
)
db_pool_overflow: int = Field(
default=40,
description="Max additional connections beyond pool_size under load (Postgres only)",
gt=0,
)
db_pool_recycle: int = Field(
default=180,
description="Recycle connections after N seconds to prevent stale connections. Default 180s works well with Neon's ~5 minute scale-to-zero (Postgres only)",
gt=0,
)
# Watch service configuration # Watch service configuration
sync_delay: int = Field( sync_delay: int = Field(
default=1000, description="Milliseconds to wait after changes before syncing", gt=0 default=1000, description="Milliseconds to wait after changes before syncing", gt=0
) )
watch_project_reload_interval: int = Field( watch_project_reload_interval: int = Field(
default=30, description="Seconds between reloading project list in watch service", gt=0 default=300,
description="Seconds between reloading project list in watch service. Higher values reduce CPU usage by minimizing watcher restarts. Default 300s (5 min) balances efficiency with responsiveness to new projects.",
gt=0,
) )
# update permalinks on move # update permalinks on move
@@ -147,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.", 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 path constraints
project_root: Optional[str] = Field( project_root: Optional[str] = Field(
default=None, default=None,
@@ -181,6 +221,34 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state", 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 @property
def cloud_mode_enabled(self) -> bool: def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled. """Check if cloud mode is enabled.
@@ -197,6 +265,36 @@ class BasicMemoryConfig(BaseSettings):
# Fall back to config file value # Fall back to config file value
return self.cloud_mode return self.cloud_mode
@classmethod
def for_cloud_tenant(
cls,
database_url: str,
projects: Optional[Dict[str, str]] = None,
) -> "BasicMemoryConfig":
"""Create config for cloud tenant - no config.json, database is source of truth.
This factory method creates a BasicMemoryConfig suitable for cloud deployments
where:
- Database is Postgres (Neon), not SQLite
- Projects are discovered from the database, not config file
- Path validation is skipped (no local filesystem in cloud)
- Initialization sync is skipped (stateless deployment)
Args:
database_url: Postgres connection URL for tenant database
projects: Optional project mapping (usually empty, discovered from DB)
Returns:
BasicMemoryConfig configured for cloud mode
"""
return cls(
database_backend=DatabaseBackend.POSTGRES,
database_url=database_url,
projects=projects or {},
cloud_mode=True,
skip_initialization_sync=True,
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_", env_prefix="BASIC_MEMORY_",
extra="ignore", extra="ignore",
@@ -213,6 +311,10 @@ class BasicMemoryConfig(BaseSettings):
def model_post_init(self, __context: Any) -> None: def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization.""" """Ensure configuration is valid after initialization."""
# Skip project initialization in cloud mode - projects are discovered from DB
if self.database_backend == DatabaseBackend.POSTGRES:
return
# Ensure at least one project exists; if none exist then create main # Ensure at least one project exists; if none exist then create main
if not self.projects: # pragma: no cover if not self.projects: # pragma: no cover
self.projects["main"] = str( self.projects["main"] = str(
@@ -255,19 +357,26 @@ class BasicMemoryConfig(BaseSettings):
"""Get all configured projects as ProjectConfig objects.""" """Get all configured projects as ProjectConfig objects."""
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()] return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
@field_validator("projects") @model_validator(mode="after")
@classmethod def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover """Ensure project paths exist.
"""Ensure project path exists."""
for name, path_value in v.items(): Skips path creation when using Postgres backend (cloud mode) since
cloud tenants don't use local filesystem paths.
"""
# Skip path creation for cloud mode - no local filesystem
if self.database_backend == DatabaseBackend.POSTGRES:
return self
for name, path_value in self.projects.items():
path = Path(path_value) path = Path(path_value)
if not Path(path).exists(): if not path.exists():
try: try:
path.mkdir(parents=True) path.mkdir(parents=True)
except Exception as e: except Exception as e:
logger.error(f"Failed to create project path: {e}") logger.error(f"Failed to create project path: {e}")
raise e raise e
return v return self
@property @property
def data_dir_path(self): def data_dir_path(self):
@@ -470,69 +579,38 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
logger.error(f"Failed to save config: {e}") logger.error(f"Failed to save config: {e}")
# setup logging to a single log file in user home directory # Logging initialization functions for different entry points
user_home = Path.home()
log_dir = user_home / DATA_DIR_NAME
log_dir.mkdir(parents=True, exist_ok=True)
# Process info for logging def init_cli_logging() -> None: # pragma: no cover
def get_process_name(): # pragma: no cover """Initialize logging for CLI commands - file only.
CLI commands should not log to stdout to avoid interfering with
command output and shell integration.
""" """
get the type of process for logging log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
""" setup_logging(log_level=log_level, log_to_file=True)
import sys
if "sync" in sys.argv:
return "sync" def init_mcp_logging() -> None: # pragma: no cover
elif "mcp" in sys.argv: """Initialize logging for MCP server - file only.
return "mcp"
elif "cli" in sys.argv: MCP server must not log to stdout as it would corrupt the
return "cli" JSON-RPC protocol communication.
"""
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
setup_logging(log_level=log_level, log_to_file=True)
def init_api_logging() -> None: # pragma: no cover
"""Initialize logging for API server.
Cloud mode (BASIC_MEMORY_CLOUD_MODE=1): stdout with structured context
Local mode: file only
"""
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
cloud_mode = os.getenv("BASIC_MEMORY_CLOUD_MODE", "").lower() in ("1", "true")
if cloud_mode:
setup_logging(log_level=log_level, log_to_stdout=True, structured_context=True)
else: else:
return "api" setup_logging(log_level=log_level, log_to_file=True)
process_name = get_process_name()
# Global flag to track if logging has been set up
_LOGGING_SETUP = False
# Logging
def setup_basic_memory_logging(): # pragma: no cover
"""Set up logging for basic-memory, ensuring it only happens once."""
global _LOGGING_SETUP
if _LOGGING_SETUP:
# We can't log before logging is set up
# print("Skipping duplicate logging setup")
return
# Check for console logging environment variable - accept more truthy values
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
console_logging = console_logging_env in ("true", "1", "yes", "on")
# Check for log level environment variable first, fall back to config
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
if not log_level:
config_manager = ConfigManager()
log_level = config_manager.config.log_level
config_manager = ConfigManager()
config = get_project_config()
setup_logging(
env=config_manager.config.env,
home_dir=user_home, # Use user home for logs
log_level=log_level,
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
console=console_logging,
)
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
_LOGGING_SETUP = True
# Set up logging
setup_basic_memory_logging()
+56 -19
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path 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.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository 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 # Module level state
_engine: Optional[AsyncEngine] = None _engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None _session_maker: Optional[async_sessionmaker[AsyncSession]] = None
@@ -33,6 +49,7 @@ class DatabaseType(Enum):
MEMORY = auto() MEMORY = auto()
FILESYSTEM = auto() FILESYSTEM = auto()
POSTGRES = auto()
@classmethod @classmethod
def get_db_url( def get_db_url(
@@ -42,7 +59,7 @@ class DatabaseType(Enum):
Args: Args:
db_path: Path to SQLite database file (ignored for Postgres) db_path: Path to SQLite database file (ignored for Postgres)
db_type: Type of database (MEMORY or FILESYSTEM) db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
config: Optional config to check for database backend and URL config: Optional config to check for database backend and URL
Returns: Returns:
@@ -52,16 +69,21 @@ class DatabaseType(Enum):
if config is None: if config is None:
config = ConfigManager().config config = ConfigManager().config
# Check if Postgres backend is configured # Handle explicit Postgres type
if db_type == cls.POSTGRES:
if not config.database_url:
raise ValueError("DATABASE_URL must be set when using Postgres backend")
logger.info(f"Using Postgres database: {config.database_url}")
return config.database_url
# Check if Postgres backend is configured (for backward compatibility)
if config.database_backend == DatabaseBackend.POSTGRES: if config.database_backend == DatabaseBackend.POSTGRES:
if not config.database_url: if not config.database_url:
raise ValueError("DATABASE_URL must be set when using Postgres backend") raise ValueError("DATABASE_URL must be set when using Postgres backend")
logger.info( logger.info(f"Using Postgres database: {config.database_url}")
f"Using Postgres database: {config.database_url.split('@')[1] if '@' in config.database_url else config.database_url}"
)
return config.database_url return config.database_url
# Default to SQLite # SQLite databases
if db_type == cls.MEMORY: if db_type == cls.MEMORY:
logger.info("Using in-memory SQLite database") logger.info("Using in-memory SQLite database")
return "sqlite+aiosqlite://" return "sqlite+aiosqlite://"
@@ -184,21 +206,37 @@ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
return engine return engine
def _create_postgres_engine(db_url: str) -> AsyncEngine: def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngine:
"""Create Postgres async engine with appropriate configuration. """Create Postgres async engine with appropriate configuration.
Args: Args:
db_url: Postgres connection URL (postgresql+asyncpg://...) db_url: Postgres connection URL (postgresql+asyncpg://...)
config: BasicMemoryConfig with pool settings
Returns: Returns:
Configured async engine for Postgres Configured async engine for Postgres
""" """
# Postgres with asyncpg - use standard async connection # Use NullPool connection issues.
# Assume connection pooler like PgBouncer handles connection pooling.
engine = create_async_engine( engine = create_async_engine(
db_url, db_url,
echo=False, echo=False,
pool_pre_ping=True, # Verify connections before using them poolclass=NullPool, # No pooling - fresh connection per request
connect_args={
# Disable statement cache to avoid issues with prepared statements on reconnect
"statement_cache_size": 0,
# Allow 30s for commands (Neon cold start can take 2-5s, sometimes longer)
"command_timeout": 30,
# Allow 30s for initial connection (Neon wake-up time)
"timeout": 30,
"server_settings": {
"application_name": "basic-memory",
# Statement timeout for queries (30s to allow for cold start)
"statement_timeout": "30s",
},
},
) )
logger.debug("Created Postgres engine with NullPool (no connection pooling)")
return engine return engine
@@ -210,7 +248,7 @@ def _create_engine_and_session(
Args: Args:
db_path: Path to database file (used for SQLite, ignored for Postgres) db_path: Path to database file (used for SQLite, ignored for Postgres)
db_type: Type of database (MEMORY or FILESYSTEM) db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
Returns: Returns:
Tuple of (engine, session_maker) Tuple of (engine, session_maker)
@@ -220,8 +258,9 @@ def _create_engine_and_session(
logger.debug(f"Creating engine for db_url: {db_url}") logger.debug(f"Creating engine for db_url: {db_url}")
# Delegate to backend-specific engine creation # Delegate to backend-specific engine creation
if config.database_backend == DatabaseBackend.POSTGRES: # Check explicit POSTGRES type first, then config setting
engine = _create_postgres_engine(db_url) if db_type == DatabaseType.POSTGRES or config.database_backend == DatabaseBackend.POSTGRES:
engine = _create_postgres_engine(db_url, config)
else: else:
engine = _create_sqlite_engine(db_url, db_type) engine = _create_sqlite_engine(db_url, db_type)
@@ -326,13 +365,8 @@ async def run_migrations(
config.set_main_option("revision_environment", "false") config.set_main_option("revision_environment", "false")
# Get the correct database URL based on backend configuration # Get the correct database URL based on backend configuration
# No URL conversion needed - env.py now handles both async and sync engines
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config) db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
if app_config.database_backend == DatabaseBackend.POSTGRES:
# Convert asyncpg URL to psycopg2 URL for Alembic
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", db_url) config.set_main_option("sqlalchemy.url", db_url)
command.upgrade(config, "head") command.upgrade(config, "head")
@@ -348,7 +382,10 @@ async def run_migrations(
# For SQLite: Create FTS5 virtual table # For SQLite: Create FTS5 virtual table
# For Postgres: No-op (tsvector column added by migrations) # For Postgres: No-op (tsvector column added by migrations)
# The project_id is not used for init_search_index, so we pass a dummy value # The project_id is not used for init_search_index, so we pass a dummy value
if app_config.database_backend == DatabaseBackend.POSTGRES: if (
database_type == DatabaseType.POSTGRES
or app_config.database_backend == DatabaseBackend.POSTGRES
):
await PostgresSearchRepository(session_maker, 1).init_search_index() await PostgresSearchRepository(session_maker, 1).init_search_index()
else: else:
await SQLiteSearchRepository(session_maker, 1).init_search_index() await SQLiteSearchRepository(session_maker, 1).init_search_index()
+296 -6
View File
@@ -76,6 +76,34 @@ async def get_project_config(
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
async def get_project_config_v2(
project_id: "ProjectIdPathDep", project_repository: "ProjectRepositoryDep"
) -> ProjectConfig: # pragma: no cover
"""Get the project config for v2 API (uses integer project_id from path).
Args:
project_id: The validated numeric project ID from the URL path
project_repository: Repository for project operations
Returns:
The resolved project config
Raises:
HTTPException: If project is not found
"""
project_obj = await project_repository.get_by_id(project_id)
if project_obj:
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
# Not found (this should not happen since ProjectIdPathDep already validates existence)
raise HTTPException( # pragma: no cover
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
)
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)] # pragma: no cover
## sqlalchemy ## sqlalchemy
@@ -130,6 +158,38 @@ ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_reposito
ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
async def validate_project_id(
project_id: int,
project_repository: ProjectRepositoryDep,
) -> int:
"""Validate that a numeric project ID exists in the database.
This is used for v2 API endpoints that take project IDs as integers in the path.
The project_id parameter will be automatically extracted from the URL path by FastAPI.
Args:
project_id: The numeric project ID from the URL path
project_repository: Repository for project operations
Returns:
The validated project ID
Raises:
HTTPException: If project with that ID is not found
"""
project_obj = await project_repository.get_by_id(project_id)
if not project_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project with ID {project_id} not found.",
)
return project_id
# V2 API: Validated integer project ID from path
ProjectIdPathDep = Annotated[int, Depends(validate_project_id)]
async def get_project_id( async def get_project_id(
project_repository: ProjectRepositoryDep, project_repository: ProjectRepositoryDep,
project: ProjectPathDep, project: ProjectPathDep,
@@ -188,6 +248,17 @@ async def get_entity_repository(
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)] EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
async def get_entity_repository_v2(
session_maker: SessionMakerDep,
project_id: ProjectIdPathDep,
) -> EntityRepository:
"""Create an EntityRepository instance for v2 API (uses integer project_id from path)."""
return EntityRepository(session_maker, project_id=project_id)
EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
async def get_observation_repository( async def get_observation_repository(
session_maker: SessionMakerDep, session_maker: SessionMakerDep,
project_id: ProjectIdDep, project_id: ProjectIdDep,
@@ -199,6 +270,19 @@ async def get_observation_repository(
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)] ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
async def get_observation_repository_v2(
session_maker: SessionMakerDep,
project_id: ProjectIdPathDep,
) -> ObservationRepository:
"""Create an ObservationRepository instance for v2 API."""
return ObservationRepository(session_maker, project_id=project_id)
ObservationRepositoryV2Dep = Annotated[
ObservationRepository, Depends(get_observation_repository_v2)
]
async def get_relation_repository( async def get_relation_repository(
session_maker: SessionMakerDep, session_maker: SessionMakerDep,
project_id: ProjectIdDep, project_id: ProjectIdDep,
@@ -210,6 +294,17 @@ async def get_relation_repository(
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)] RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
async def get_relation_repository_v2(
session_maker: SessionMakerDep,
project_id: ProjectIdPathDep,
) -> RelationRepository:
"""Create a RelationRepository instance for v2 API."""
return RelationRepository(session_maker, project_id=project_id)
RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
async def get_search_repository( async def get_search_repository(
session_maker: SessionMakerDep, session_maker: SessionMakerDep,
project_id: ProjectIdDep, project_id: ProjectIdDep,
@@ -225,6 +320,17 @@ async def get_search_repository(
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)] SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
async def get_search_repository_v2(
session_maker: SessionMakerDep,
project_id: ProjectIdPathDep,
) -> SearchRepository:
"""Create a SearchRepository instance for v2 API."""
return create_search_repository(session_maker, project_id=project_id)
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
# ProjectInfoRepository is deprecated and will be removed in a future version. # ProjectInfoRepository is deprecated and will be removed in a future version.
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations. # Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
@@ -238,27 +344,61 @@ async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)] EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor: async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityParser:
return MarkdownProcessor(entity_parser) return EntityParser(project_config.home)
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
async def get_markdown_processor(
entity_parser: EntityParserDep, app_config: AppConfigDep
) -> MarkdownProcessor:
return MarkdownProcessor(entity_parser, app_config=app_config)
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)] MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
async def get_markdown_processor_v2(
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
) -> MarkdownProcessor:
return MarkdownProcessor(entity_parser, app_config=app_config)
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
async def get_file_service( async def get_file_service(
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep project_config: ProjectConfigDep,
markdown_processor: MarkdownProcessorDep,
app_config: AppConfigDep,
) -> FileService: ) -> FileService:
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
logger.debug( logger.debug(
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}" f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
) )
file_service = FileService(project_config.home, markdown_processor)
logger.debug(f"Created FileService for project: {file_service} ")
return file_service return file_service
FileServiceDep = Annotated[FileService, Depends(get_file_service)] FileServiceDep = Annotated[FileService, Depends(get_file_service)]
async def get_file_service_v2(
project_config: ProjectConfigV2Dep,
markdown_processor: MarkdownProcessorV2Dep,
app_config: AppConfigDep,
) -> FileService:
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
logger.debug(
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
)
return file_service
FileServiceV2Dep = Annotated[FileService, Depends(get_file_service_v2)]
async def get_entity_service( async def get_entity_service(
entity_repository: EntityRepositoryDep, entity_repository: EntityRepositoryDep,
observation_repository: ObservationRepositoryDep, observation_repository: ObservationRepositoryDep,
@@ -266,6 +406,7 @@ async def get_entity_service(
entity_parser: EntityParserDep, entity_parser: EntityParserDep,
file_service: FileServiceDep, file_service: FileServiceDep,
link_resolver: "LinkResolverDep", link_resolver: "LinkResolverDep",
search_service: "SearchServiceDep",
app_config: AppConfigDep, app_config: AppConfigDep,
) -> EntityService: ) -> EntityService:
"""Create EntityService with repository.""" """Create EntityService with repository."""
@@ -276,6 +417,7 @@ async def get_entity_service(
entity_parser=entity_parser, entity_parser=entity_parser,
file_service=file_service, file_service=file_service,
link_resolver=link_resolver, link_resolver=link_resolver,
search_service=search_service,
app_config=app_config, app_config=app_config,
) )
@@ -283,6 +425,32 @@ async def get_entity_service(
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)] EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
async def get_entity_service_v2(
entity_repository: EntityRepositoryV2Dep,
observation_repository: ObservationRepositoryV2Dep,
relation_repository: RelationRepositoryV2Dep,
entity_parser: EntityParserV2Dep,
file_service: FileServiceV2Dep,
link_resolver: "LinkResolverV2Dep",
search_service: "SearchServiceV2Dep",
app_config: AppConfigDep,
) -> EntityService:
"""Create EntityService for v2 API."""
return EntityService(
entity_repository=entity_repository,
observation_repository=observation_repository,
relation_repository=relation_repository,
entity_parser=entity_parser,
file_service=file_service,
link_resolver=link_resolver,
search_service=search_service,
app_config=app_config,
)
EntityServiceV2Dep = Annotated[EntityService, Depends(get_entity_service_v2)]
async def get_search_service( async def get_search_service(
search_repository: SearchRepositoryDep, search_repository: SearchRepositoryDep,
entity_repository: EntityRepositoryDep, entity_repository: EntityRepositoryDep,
@@ -295,6 +463,18 @@ async def get_search_service(
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)] SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
async def get_search_service_v2(
search_repository: SearchRepositoryV2Dep,
entity_repository: EntityRepositoryV2Dep,
file_service: FileServiceV2Dep,
) -> SearchService:
"""Create SearchService for v2 API."""
return SearchService(search_repository, entity_repository, file_service)
SearchServiceV2Dep = Annotated[SearchService, Depends(get_search_service_v2)]
async def get_link_resolver( async def get_link_resolver(
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
) -> LinkResolver: ) -> LinkResolver:
@@ -304,6 +484,15 @@ async def get_link_resolver(
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)] LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
async def get_link_resolver_v2(
entity_repository: EntityRepositoryV2Dep, search_service: SearchServiceV2Dep
) -> LinkResolver:
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
LinkResolverV2Dep = Annotated[LinkResolver, Depends(get_link_resolver_v2)]
async def get_context_service( async def get_context_service(
search_repository: SearchRepositoryDep, search_repository: SearchRepositoryDep,
entity_repository: EntityRepositoryDep, entity_repository: EntityRepositoryDep,
@@ -319,6 +508,22 @@ async def get_context_service(
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)] ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
async def get_context_service_v2(
search_repository: SearchRepositoryV2Dep,
entity_repository: EntityRepositoryV2Dep,
observation_repository: ObservationRepositoryV2Dep,
) -> ContextService:
"""Create ContextService for v2 API."""
return ContextService(
search_repository=search_repository,
entity_repository=entity_repository,
observation_repository=observation_repository,
)
ContextServiceV2Dep = Annotated[ContextService, Depends(get_context_service_v2)]
async def get_sync_service( async def get_sync_service(
app_config: AppConfigDep, app_config: AppConfigDep,
entity_service: EntityServiceDep, entity_service: EntityServiceDep,
@@ -348,6 +553,32 @@ async def get_sync_service(
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)] SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
async def get_sync_service_v2(
app_config: AppConfigDep,
entity_service: EntityServiceV2Dep,
entity_parser: EntityParserV2Dep,
entity_repository: EntityRepositoryV2Dep,
relation_repository: RelationRepositoryV2Dep,
project_repository: ProjectRepositoryDep,
search_service: SearchServiceV2Dep,
file_service: FileServiceV2Dep,
) -> SyncService: # pragma: no cover
"""Create SyncService for v2 API."""
return SyncService(
app_config=app_config,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
project_repository=project_repository,
search_service=search_service,
file_service=file_service,
)
SyncServiceV2Dep = Annotated[SyncService, Depends(get_sync_service_v2)]
async def get_project_service( async def get_project_service(
project_repository: ProjectRepositoryDep, project_repository: ProjectRepositoryDep,
) -> ProjectService: ) -> ProjectService:
@@ -370,6 +601,18 @@ async def get_directory_service(
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)] DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
async def get_directory_service_v2(
entity_repository: EntityRepositoryV2Dep,
) -> DirectoryService:
"""Create DirectoryService for v2 API (uses integer project_id from path)."""
return DirectoryService(
entity_repository=entity_repository,
)
DirectoryServiceV2Dep = Annotated[DirectoryService, Depends(get_directory_service_v2)]
# Import # Import
@@ -413,3 +656,50 @@ async def get_memory_json_importer(
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)] MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
# V2 Import dependencies
async def get_chatgpt_importer_v2(
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
) -> ChatGPTImporter:
"""Create ChatGPTImporter with v2 dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor)
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
async def get_claude_conversations_importer_v2(
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with v2 dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor)
ClaudeConversationsImporterV2Dep = Annotated[
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2)
]
async def get_claude_projects_importer_v2(
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with v2 dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor)
ClaudeProjectsImporterV2Dep = Annotated[
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2)
]
async def get_memory_json_importer_v2(
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with v2 dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor)
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
+212 -3
View File
@@ -1,9 +1,13 @@
"""Utilities for file operations.""" """Utilities for file operations."""
import asyncio
import hashlib import hashlib
import shlex
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
import re import re
from typing import Any, Dict, Union from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import aiofiles import aiofiles
import yaml import yaml
@@ -12,6 +16,23 @@ from loguru import logger
from basic_memory.utils import FilePath from basic_memory.utils import FilePath
if TYPE_CHECKING:
from basic_memory.config import BasicMemoryConfig
@dataclass
class FileMetadata:
"""File metadata for cloud-compatible file operations.
This dataclass provides a cloud-agnostic way to represent file metadata,
enabling S3FileService to return metadata from head_object responses
instead of mock stat_result with zeros.
"""
size: int
created_at: datetime
modified_at: datetime
class FileError(Exception): class FileError(Exception):
"""Base exception for file operations.""" """Base exception for file operations."""
@@ -53,6 +74,28 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
raise FileError(f"Failed to compute checksum: {e}") 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: async def write_file_atomic(path: FilePath, content: str) -> None:
""" """
Write file with atomic operation using temporary file. Write file with atomic operation using temporary file.
@@ -84,6 +127,168 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
raise FileWriteError(f"Failed to write file {path}: {e}") 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: def has_frontmatter(content: str) -> bool:
""" """
Check if content contains valid YAML frontmatter. Check if content contains valid YAML frontmatter.
@@ -97,7 +302,8 @@ def has_frontmatter(content: str) -> bool:
if not content: if not content:
return False return False
content = content.strip() # Strip BOM before checking for frontmatter markers
content = strip_bom(content).strip()
if not content.startswith("---"): if not content.startswith("---"):
return False return False
@@ -118,6 +324,8 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
ParseError: If frontmatter is invalid or parsing fails ParseError: If frontmatter is invalid or parsing fails
""" """
try: try:
# Strip BOM before parsing frontmatter
content = strip_bom(content)
if not content.strip().startswith("---"): if not content.strip().startswith("---"):
raise ParseError("Content has no frontmatter") raise ParseError("Content has no frontmatter")
@@ -159,7 +367,8 @@ def remove_frontmatter(content: str) -> str:
Raises: Raises:
ParseError: If content starts with frontmatter marker but is malformed 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 # Return as-is if no frontmatter marker
if not content.startswith("---"): if not content.startswith("---"):
@@ -40,10 +40,13 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
chats_imported = 0 chats_imported = 0
for chat in conversations: for chat in conversations:
# Get name, providing default for unnamed conversations
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
# Convert to entity # Convert to entity
entity = self._format_chat_content( entity = self._format_chat_content(
base_path=folder_path, base_path=folder_path,
name=chat["name"], name=chat_name,
messages=chat["chat_messages"], messages=chat["chat_messages"],
created_at=chat["created_at"], created_at=chat["created_at"],
modified_at=chat["updated_at"], modified_at=chat["updated_at"],
+5 -2
View File
@@ -5,15 +5,18 @@ from datetime import datetime
from typing import Any 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. """Clean a string to be used as a filename.
Args: Args:
name: The string to clean. name: The string to clean (can be None).
Returns: Returns:
A cleaned string suitable for use as a filename. 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 # Replace common punctuation and whitespace with underscores
name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name) name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
# Remove any non-alphanumeric or underscore characters # Remove any non-alphanumeric or underscore characters
+65 -26
View File
@@ -23,6 +23,7 @@ from basic_memory.markdown.schemas import (
) )
from basic_memory.utils import parse_tags from basic_memory.utils import parse_tags
md = MarkdownIt().use(observation_plugin).use(relation_plugin) md = MarkdownIt().use(observation_plugin).use(relation_plugin)
@@ -189,35 +190,69 @@ class EntityParser:
return self.base_path / path return self.base_path / path
async def parse_file_content(self, absolute_path, file_content): async def parse_file_content(self, absolute_path, file_content):
# Parse frontmatter with proper error handling for malformed YAML (issue #185) """Parse markdown content from file stats.
try:
post = frontmatter.loads(file_content)
except yaml.YAMLError as e:
# Log the YAML parsing error with file context
logger.warning(
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
f"Treating file as plain markdown without frontmatter."
)
# Create a post with no frontmatter - treat entire content as markdown
post = frontmatter.Post(file_content, metadata={})
# Extract file stat info Delegates to parse_markdown_content() for actual parsing logic.
Exists for backwards compatibility with code that passes file paths.
"""
# Extract file stat info for timestamps
file_stats = absolute_path.stat() file_stats = absolute_path.stat()
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236) # Delegate to parse_markdown_content with timestamps from file stats
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects return await self.parse_markdown_content(
# This normalization converts them back to ISO format strings to ensure compatibility file_path=absolute_path,
# with code that expects string values content=file_content,
mtime=file_stats.st_mtime,
ctime=file_stats.st_ctime,
)
async def parse_markdown_content(
self,
file_path: Path,
content: str,
mtime: Optional[float] = None,
ctime: Optional[float] = None,
) -> EntityMarkdown:
"""Parse markdown content without requiring file to exist on disk.
Useful for parsing content from S3 or other remote sources where the file
is not available locally.
Args:
file_path: Path for metadata (doesn't need to exist on disk)
content: Markdown content as string
mtime: Optional modification time (Unix timestamp)
ctime: Optional creation time (Unix timestamp)
Returns:
EntityMarkdown with parsed content
"""
# Strip BOM before parsing (can be present in files from Windows or certain sources)
# See issue #452
from basic_memory.file_utils import strip_bom
content = strip_bom(content)
# Parse frontmatter with proper error handling for malformed YAML
try:
post = frontmatter.loads(content)
except yaml.YAMLError as e:
logger.warning(
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
f"Treating file as plain markdown without frontmatter."
)
post = frontmatter.Post(content, metadata={})
# Normalize frontmatter values
metadata = normalize_frontmatter_metadata(post.metadata) metadata = normalize_frontmatter_metadata(post.metadata)
# Ensure required fields have defaults (issue #184, #387) # Ensure required fields have defaults
# Handle title - use default if missing, None/null, empty, or string "None"
title = metadata.get("title") title = metadata.get("title")
if not title or title == "None": if not title or title == "None":
metadata["title"] = absolute_path.stem metadata["title"] = file_path.stem
else: else:
metadata["title"] = title metadata["title"] = title
# Handle type - use default if missing OR explicitly set to None/null
entity_type = metadata.get("type") entity_type = metadata.get("type")
metadata["type"] = entity_type if entity_type is not None else "note" metadata["type"] = entity_type if entity_type is not None else "note"
@@ -225,16 +260,20 @@ class EntityParser:
if tags: if tags:
metadata["tags"] = tags metadata["tags"] = tags
# frontmatter - use metadata with defaults applied # Parse content for observations and relations
entity_frontmatter = EntityFrontmatter( entity_frontmatter = EntityFrontmatter(metadata=metadata)
metadata=metadata,
)
entity_content = parse(post.content) entity_content = parse(post.content)
# Use provided timestamps or current time as fallback
now = datetime.now().astimezone()
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
return EntityMarkdown( return EntityMarkdown(
frontmatter=entity_frontmatter, frontmatter=entity_frontmatter,
content=post.content, content=post.content,
observations=entity_content.observations, observations=entity_content.observations,
relations=entity_content.relations, relations=entity_content.relations,
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(), created=created,
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(), modified=modified,
) )
@@ -1,15 +1,19 @@
from pathlib import Path from pathlib import Path
from typing import Optional from typing import TYPE_CHECKING, Optional
from collections import OrderedDict from collections import OrderedDict
from frontmatter import Post from frontmatter import Post
from loguru import logger from loguru import logger
from basic_memory import file_utils from basic_memory import file_utils
from basic_memory.file_utils import dump_frontmatter from basic_memory.file_utils import dump_frontmatter
from basic_memory.markdown.entity_parser import EntityParser from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
if TYPE_CHECKING:
from basic_memory.config import BasicMemoryConfig
class DirtyFileError(Exception): class DirtyFileError(Exception):
"""Raised when attempting to write to a file that has been modified.""" """Raised when attempting to write to a file that has been modified."""
@@ -35,9 +39,14 @@ class MarkdownProcessor:
3. Track schema changes (that's done by the database) 3. Track schema changes (that's done by the database)
""" """
def __init__(self, entity_parser: EntityParser): def __init__(
"""Initialize processor with base path and parser.""" self,
entity_parser: EntityParser,
app_config: Optional["BasicMemoryConfig"] = None,
):
"""Initialize processor with parser and optional config."""
self.entity_parser = entity_parser self.entity_parser = entity_parser
self.app_config = app_config
async def read_file(self, path: Path) -> EntityMarkdown: async def read_file(self, path: Path) -> EntityMarkdown:
"""Read and parse file into EntityMarkdown schema. """Read and parse file into EntityMarkdown schema.
@@ -122,7 +131,17 @@ class MarkdownProcessor:
# Write atomically and return checksum of updated file # Write atomically and return checksum of updated file
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
await file_utils.write_file_atomic(path, final_content) 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: def format_observations(self, observations: list[Observation]) -> str:
"""Format observations section in standard way. """Format observations section in standard way.
+4 -2
View File
@@ -30,7 +30,9 @@ def is_observation(token: Token) -> bool:
# Check for proper observation format: [category] content # Check for proper observation format: [category] content
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content) match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
has_tags = "#" in content # Check for standalone hashtags (words starting with #)
# This excludes # in HTML attributes like color="#4285F4"
has_tags = any(part.startswith("#") for part in content.split())
return bool(match) or has_tags return bool(match) or has_tags
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
target = content[start + 2 : end].strip() target = content[start + 2 : end].strip()
if target: if target:
relations.append({"type": "links to", "target": target, "context": None}) relations.append({"type": "links_to", "target": target, "context": None})
start = end + 2 start = end + 2
+10 -1
View File
@@ -3,6 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from frontmatter import Post from frontmatter import Post
from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
@@ -12,7 +13,10 @@ from basic_memory.models import Observation as ObservationModel
def entity_model_from_markdown( def entity_model_from_markdown(
file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None file_path: Path,
markdown: EntityMarkdown,
entity: Optional[Entity] = None,
project_id: Optional[int] = None,
) -> Entity: ) -> Entity:
""" """
Convert markdown entity to model. Does not include relations. Convert markdown entity to model. Does not include relations.
@@ -21,6 +25,7 @@ def entity_model_from_markdown(
file_path: Path to the markdown file file_path: Path to the markdown file
markdown: Parsed markdown entity markdown: Parsed markdown entity
entity: Optional existing entity to update entity: Optional existing entity to update
project_id: Project ID for new observations (uses entity.project_id if not provided)
Returns: Returns:
Entity model populated from markdown Entity model populated from markdown
@@ -50,9 +55,13 @@ def entity_model_from_markdown(
metadata = markdown.frontmatter.metadata or {} metadata = markdown.frontmatter.metadata or {}
model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None} model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
# Get project_id from entity if not provided
obs_project_id = project_id or (model.project_id if hasattr(model, "project_id") else None)
# Convert observations # Convert observations
model.observations = [ model.observations = [
ObservationModel( ObservationModel(
project_id=obs_project_id,
content=obs.content, content=obs.content,
category=obs.category, category=obs.category,
context=obs.context, context=obs.context,
+1
View File
@@ -95,6 +95,7 @@ async def get_client() -> AsyncIterator[AsyncClient]:
yield client yield client
else: else:
# Local mode: ASGI transport for in-process calls # 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") logger.info("Creating ASGI client for local Basic Memory API")
async with AsyncClient( async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
+72
View File
@@ -2,8 +2,80 @@
Basic Memory FastMCP server. Basic Memory FastMCP server.
""" """
import asyncio
from contextlib import asynccontextmanager
from fastmcp import FastMCP 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( mcp = FastMCP(
name="Basic Memory", 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.project_context import get_active_project
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get 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.base import TimeFrame
from basic_memory.schemas.memory import ( from basic_memory.schemas.memory import (
GraphContext, GraphContext,
@@ -87,6 +88,7 @@ async def build_context(
Raises: Raises:
ToolError: If project doesn't exist or depth parameter is invalid 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}") logger.info(f"Building context from {url} in project {project}")
# Convert string depth to integer if needed # Convert string depth to integer if needed
@@ -104,11 +106,9 @@ async def build_context(
# Get the active project using the new stateless approach # Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
response = await call_get( response = await call_get(
client, client,
f"{project_url}/memory/{memory_url_path(url)}", f"/v2/projects/{active_project.id}/memory/{memory_url_path(url)}",
params={ params={
"depth": depth, "depth": depth,
"timeframe": timeframe, "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.async_client import get_client
from basic_memory.mcp.project_context import get_active_project from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp 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( @mcp.tool(
@@ -94,9 +95,9 @@ async def canvas(
Raises: Raises:
ToolError: If project doesn't exist or folder path is invalid ToolError: If project doesn't exist or folder path is invalid
""" """
track_mcp_tool("canvas")
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Ensure path has .canvas extension # Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas" file_title = title if title.endswith(".canvas") else f"{title}.canvas"
@@ -108,23 +109,44 @@ async def canvas(
# Convert to JSON # Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2) 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}") logger.info(f"Creating canvas file: {file_path} in project {project}")
# Send canvas_json as content string, not as json parameter try:
# The resource endpoint expects Body() string content, not JSON-encoded data response = await call_post(
response = await call_put( client,
client, f"/v2/projects/{active_project.id}/resource",
f"{project_url}/resource/{file_path}", json={"file_path": file_path, "content": canvas_json},
content=canvas_json, )
headers={"Content-Type": "text/plain"}, 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 # Parse response
result = response.json() result = response.json()
logger.debug(result) logger.debug(result)
# Build summary # Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."] summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
return "\n".join(summary) 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.mcp.tools.read_note import read_note
from basic_memory.schemas.search import SearchResponse from basic_memory.schemas.search import SearchResponse
from basic_memory.config import ConfigManager 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]]: 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...}" }` List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body contains `results`, `total_count`, and echo of `query`. where the JSON body contains `results`, `total_count`, and echo of `query`.
""" """
track_mcp_tool("search")
logger.info(f"ChatGPT search request: query='{query}'") logger.info(f"ChatGPT search request: query='{query}'")
try: try:
@@ -151,6 +153,7 @@ async def fetch(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }` List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body includes `id`, `title`, `text`, `url`, and metadata. where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
""" """
track_mcp_tool("fetch")
logger.info(f"ChatGPT fetch request: id='{id}'") logger.info(f"ChatGPT fetch request: id='{id}'")
try: try:
+20 -3
View File
@@ -3,11 +3,13 @@ from typing import Optional
from loguru import logger from loguru import logger
from fastmcp import Context 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.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.server import mcp
from basic_memory.mcp.async_client import get_client from basic_memory.mcp.async_client import get_client
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas import DeleteEntitiesResponse from basic_memory.schemas import DeleteEntitiesResponse
@@ -202,12 +204,27 @@ async def delete_note(
with suggestions for finding the correct identifier, including search with suggestions for finding the correct identifier, including search
commands and alternative formats to try. commands and alternative formats to try.
""" """
track_mcp_tool("delete_note")
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
try: 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()) result = DeleteEntitiesResponse.model_validate(response.json())
if result.deleted: 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.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp 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 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 search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail. error messages with suggestions if operations fail.
""" """
track_mcp_tool("edit_note")
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) 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) 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 # Use the PATCH endpoint to edit the entity
try: try:
# Resolve identifier to entity ID
entity_id = await resolve_entity_id(client, active_project.id, identifier)
# Prepare the edit request data # Prepare the edit request data
edit_data = { edit_data = {
"operation": operation, "operation": operation,
@@ -250,7 +254,7 @@ async def edit_note(
edit_data["expected_replacements"] = str(expected_replacements) edit_data["expected_replacements"] = str(expected_replacements)
# Call the PATCH endpoint # 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) response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json()) 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.project_context import get_active_project
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool
@mcp.tool( @mcp.tool(
@@ -63,9 +64,9 @@ async def list_directory(
Raises: Raises:
ToolError: If project doesn't exist or directory path is invalid ToolError: If project doesn't exist or directory path is invalid
""" """
track_mcp_tool("list_directory")
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Prepare query parameters # Prepare query parameters
params = { params = {
@@ -82,7 +83,7 @@ async def list_directory(
# Call the API endpoint # Call the API endpoint
response = await call_get( response = await call_get(
client, client,
f"{project_url}/directory/list", f"/v2/projects/{active_project.id}/directory/list",
params=params, 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.async_client import get_client
from basic_memory.mcp.server import mcp 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.mcp.project_context import get_active_project
from basic_memory.schemas import EntityResponse from basic_memory.schemas import EntityResponse
from basic_memory.schemas.project_info import ProjectList from basic_memory.schemas.project_info import ProjectList
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path from basic_memory.utils import validate_project_path
@@ -395,11 +396,11 @@ async def move_note(
- Re-indexes the entity for search - Re-indexes the entity for search
- Maintains all observations and relations - Maintains all observations and relations
""" """
track_mcp_tool("move_note")
async with get_client() as client: async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}") logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Validate destination path to prevent path traversal attacks # Validate destination path to prevent path traversal attacks
project_path = active_project.home 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 # Get the source entity information for extension validation
source_ext = "md" # Default to .md if we can't determine source extension source_ext = "md" # Default to .md if we can't determine source extension
try: 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 # 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) response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json()) source_entity = EntityResponse.model_validate(response.json())
if "." in source_entity.file_path: 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 # Get the source entity to check its file extension
try: 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 # 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) response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json()) 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}") logger.debug(f"Could not fetch source entity for extension check: {e}")
try: 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 = { move_data = {
"identifier": identifier,
"destination_path": destination_path, "destination_path": destination_path,
"project": active_project.name,
} }
# Call the move API endpoint # Call the v2 move API endpoint (PUT method, entity_id in URL)
url = f"{project_url}/knowledge/move" url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}/move"
response = await call_post(client, url, json=move_data) response = await call_put(client, url, json=move_data)
result = EntityResponse.model_validate(response.json()) result = EntityResponse.model_validate(response.json())
# Build success message # Build success message
@@ -15,6 +15,7 @@ from basic_memory.schemas.project_info import (
ProjectStatusResponse, ProjectStatusResponse,
ProjectInfoRequest, ProjectInfoRequest,
) )
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import generate_permalink from basic_memory.utils import generate_permalink
@@ -40,6 +41,7 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example: Example:
list_memory_projects() list_memory_projects()
""" """
track_mcp_tool("list_memory_projects")
async with get_client() as client: async with get_client() as client:
if context: # pragma: no cover if context: # pragma: no cover
await context.info("Listing all available projects") 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("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True) create_memory_project("work-notes", "/home/user/work", set_default=True)
""" """
track_mcp_tool("create_memory_project")
async with get_client() as client: async with get_client() as client:
# Check if server is constrained to a specific project # Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_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 This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again. to access its content through Basic Memory again.
""" """
track_mcp_tool("delete_project")
async with get_client() as client: async with get_client() as client:
# Check if server is constrained to a specific project # Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_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)}" f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
) )
# Call API to delete project using URL encoding for special characters # Call v2 API to delete project using project ID
from urllib.parse import quote response = await call_delete(client, f"/v2/projects/{target_project.id}")
encoded_name = quote(target_project.name, safe="")
response = await call_delete(client, f"/projects/{encoded_name}")
status_response = ProjectStatusResponse.model_validate(response.json()) status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n" result = f"{status_response.message}\n\n"
+13 -3
View File
@@ -13,12 +13,14 @@ from typing import Optional
from loguru import logger from loguru import logger
from PIL import Image as PILImage from PIL import Image as PILImage
from fastmcp import Context 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.project_context import get_active_project
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client 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.schemas.memory import memory_url_path
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path 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 HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal SecurityError: If path attempts path traversal
""" """
track_mcp_tool("read_content")
logger.info("Reading file", path=path, project=project) logger.info("Reading file", path=path, project=project)
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
url = memory_url_path(path) 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", "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_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0)) 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.project_context import get_active_project
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes 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.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_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 If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates. including related notes, search commands, and note creation templates.
""" """
track_mcp_tool("read_note")
async with get_client() as client: async with get_client() as client:
# Get and validate the project # Get and validate the project
active_project = await get_active_project(client, project, context) 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" 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 identifier resolution
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier) entity_path = memory_url_path(identifier)
path = f"{project_url}/resource/{entity_path}" logger.info(
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}") f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
)
try: try:
# Try direct lookup first # Try to resolve identifier to entity ID
response = await call_get(client, path, params={"page": page, "page_size": page_size}) 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 successful, return the content
if response.status_code == 200: if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path) logger.info("Returning read_note result from resource: {path}", path=entity_path)
return response.text return response.text
except Exception as e: # pragma: no cover 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 # Continue to fallback methods
# Fallback 1: Try title search via API # 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 result = title_results.results[0] # Get the first/best match
if result.permalink: if result.permalink:
try: try:
# Try to fetch the content using the found permalink # Resolve the permalink to entity ID
path = f"{project_url}/resource/{result.permalink}" entity_id = await resolve_entity_id(client, active_project.id, result.permalink)
# Fetch content using the entity ID
response = await call_get( 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: 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.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get 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.base import TimeFrame
from basic_memory.schemas.memory import ( from basic_memory.schemas.memory import (
GraphContext, GraphContext,
@@ -98,6 +99,7 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI - For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past - Max timeframe is 1 year in the past
""" """
track_mcp_tool("recent_activity")
async with get_client() as client: async with get_client() as client:
# Build common parameters for API calls # Build common parameters for API calls
params = { params = {
@@ -247,11 +249,10 @@ async def recent_activity(
) )
active_project = await get_active_project(client, resolved_project, context) active_project = await get_active_project(client, resolved_project, context)
project_url = active_project.project_url
response = await call_get( response = await call_get(
client, client,
f"{project_url}/memory/recent", f"/v2/projects/{active_project.id}/memory/recent",
params=params, params=params,
) )
activity_data = GraphContext.model_validate(response.json()) activity_data = GraphContext.model_validate(response.json())
@@ -274,10 +275,9 @@ async def _get_project_activity(
Returns: Returns:
ProjectActivity with activity data or empty activity on error ProjectActivity with activity data or empty activity on error
""" """
project_url = f"/{project_info.permalink}"
activity_response = await call_get( activity_response = await call_get(
client, client,
f"{project_url}/memory/recent", f"/v2/projects/{project_info.id}/memory/recent",
params=params, params=params,
) )
activity = GraphContext.model_validate(activity_response.json()) 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.project_context import get_active_project
from basic_memory.mcp.server import mcp from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post 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 from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -330,6 +331,7 @@ async def search_notes(
# Explicit project specification # Explicit project specification
results = await search_notes("project planning", project="my-project") results = await search_notes("project planning", project="my-project")
""" """
track_mcp_tool("search_notes")
# Create a SearchQuery object based on the parameters # Create a SearchQuery object based on the parameters
search_query = SearchQuery() search_query = SearchQuery()
@@ -355,14 +357,13 @@ async def search_notes(
async with get_client() as client: async with get_client() as client:
active_project = await get_active_project(client, project, context) 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}") logger.info(f"Searching for {search_query} in project {active_project.name}")
try: try:
response = await call_post( response = await call_post(
client, client,
f"{project_url}/search/", f"/v2/projects/{active_project.id}/search/",
json=search_query.model_dump(), json=search_query.model_dump(),
params={"page": page, "page_size": page_size}, params={"page": page, "page_size": page_size},
) )
+28
View File
@@ -435,6 +435,34 @@ async def call_post(
raise ToolError(error_message) from e 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( async def call_delete(
client: AsyncClient, client: AsyncClient,
url: URL | str, 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.server import mcp
from basic_memory.mcp.tools.read_note import read_note from basic_memory.mcp.tools.read_note import read_note
from basic_memory.telemetry import track_mcp_tool
@mcp.tool( @mcp.tool(
@@ -54,7 +55,7 @@ async def view_note(
HTTPError: If project doesn't exist or is inaccessible HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal SecurityError: If identifier attempts path traversal
""" """
track_mcp_tool("view_note")
logger.info(f"Viewing note: {identifier} in project: {project}") logger.info(f"Viewing note: {identifier} in project: {project}")
# Call the existing read_note logic # 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.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp 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 basic_memory.schemas import EntityResponse
from fastmcp import Context from fastmcp import Context
from basic_memory.schemas.base import Entity from basic_memory.schemas.base import Entity
@@ -116,6 +117,7 @@ async def write_note(
HTTPError: If project doesn't exist or is inaccessible HTTPError: If project doesn't exist or is inaccessible
SecurityError: If folder path attempts path traversal SecurityError: If folder path attempts path traversal
""" """
track_mcp_tool("write_note")
async with get_client() as client: async with get_client() as client:
logger.info( logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}" 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, content=content,
entity_metadata=metadata, entity_metadata=metadata,
) )
project_url = active_project.permalink
# Create or update via knowledge API # Try to create the entity first (optimistic create)
logger.debug(f"Creating entity via API permalink={entity.permalink}") logger.debug(f"Attempting to create entity permalink={entity.permalink}")
url = f"{project_url}/knowledge/entities/{entity.permalink}" action = "Created" # Default to created
response = await call_put(client, url, json=entity.model_dump()) try:
result = EntityResponse.model_validate(response.json()) url = f"/v2/projects/{active_project.id}/knowledge/entities"
response = await call_post(client, url, json=entity.model_dump())
# Format semantic summary based on status code result = EntityResponse.model_validate(response.json())
action = "Created" if response.status_code == 201 else "Updated" 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 = [ summary = [
f"# {action} note", f"# {action} note",
f"project: {active_project.name}", f"project: {active_project.name}",
-2
View File
@@ -4,7 +4,6 @@ import basic_memory
from basic_memory.models.base import Base from basic_memory.models.base import Base
from basic_memory.models.knowledge import Entity, Observation, Relation from basic_memory.models.knowledge import Entity, Observation, Relation
from basic_memory.models.project import Project from basic_memory.models.project import Project
from basic_memory.models.search import SearchIndex
__all__ = [ __all__ = [
"Base", "Base",
@@ -12,6 +11,5 @@ __all__ = [
"Observation", "Observation",
"Relation", "Relation",
"Project", "Project",
"SearchIndex",
"basic_memory", "basic_memory",
] ]
+9 -2
View File
@@ -129,7 +129,7 @@ class Entity(Base):
return value return value
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'" return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
class Observation(Base): class Observation(Base):
@@ -145,6 +145,7 @@ class Observation(Base):
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE")) entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
category: Mapped[str] = mapped_column(String, nullable=False, default="note") category: Mapped[str] = mapped_column(String, nullable=False, default="note")
@@ -162,9 +163,14 @@ class Observation(Base):
We can construct these because observations are always defined in We can construct these because observations are always defined in
and owned by a single entity. and owned by a single entity.
Content is truncated to 200 chars to stay under PostgreSQL's
btree index limit of 2704 bytes.
""" """
# Truncate content to avoid exceeding PostgreSQL's btree index limit
content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
return generate_permalink( return generate_permalink(
f"{self.entity.permalink}/observations/{self.category}/{self.content}" f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
) )
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
@@ -186,6 +192,7 @@ class Relation(Base):
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE")) from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
to_id: Mapped[Optional[int]] = mapped_column( to_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
+43 -44
View File
@@ -1,53 +1,52 @@
"""Search models and tables.""" """Search DDL statements for SQLite and Postgres.
from sqlalchemy import DDL, Column, Integer, String, DateTime, Text The search_index table is created via raw DDL, not ORM models, because:
from sqlalchemy.dialects.postgresql import JSONB - SQLite uses FTS5 virtual tables (cannot be represented as ORM)
from sqlalchemy.types import JSON - Postgres uses composite primary keys and generated tsvector columns
- Both backends use raw SQL for all search operations via SearchIndexRow dataclass
"""
from basic_memory.models.base import Base from sqlalchemy import DDL
class SearchIndex(Base): # Define Postgres search_index table with composite primary key and tsvector
"""Search index table for Postgres only. # This DDL matches the Alembic migration schema (314f1ea54dc4)
# Used by tests to create the table without running full migrations
# NOTE: Split into separate DDL statements because asyncpg doesn't support
# multiple statements in a single execute call.
CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL("""
CREATE TABLE IF NOT EXISTS search_index (
id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
title TEXT,
content_stems TEXT,
content_snippet TEXT,
permalink VARCHAR,
file_path VARCHAR,
type VARCHAR,
from_id INTEGER,
to_id INTEGER,
relation_type VARCHAR,
entity_id INTEGER,
category VARCHAR,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE,
textsearchable_index_col tsvector GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
) STORED,
PRIMARY KEY (id, type, project_id),
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
)
""")
For SQLite: This model is skipped; FTS5 virtual table is created via DDL instead. CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
For Postgres: This is the actual table structure with tsvector support. CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
""" """)
__tablename__ = "search_index"
# Primary key (rowid in SQLite FTS5, explicit id in Postgres)
id = Column(Integer, primary_key=True, autoincrement=True)
# Core searchable fields
title = Column(Text, nullable=True)
content_stems = Column(Text, nullable=True)
content_snippet = Column(Text, nullable=True)
permalink = Column(String(255), nullable=True, index=True)
file_path = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
# Project context
project_id = Column(Integer, nullable=True, index=True)
# Relation fields
from_id = Column(Integer, nullable=True)
to_id = Column(Integer, nullable=True)
relation_type = Column(String(100), nullable=True)
# Observation fields
entity_id = Column(Integer, nullable=True)
category = Column(String(100), nullable=True)
# Common fields
# Use JSONB for Postgres, JSON for SQLite
# Note: 'metadata' is a reserved name in SQLAlchemy, so we use 'metadata_' and map to 'metadata'
metadata_ = Column("metadata", JSON().with_variant(JSONB(), "postgresql"), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), nullable=True)
# Note: textsearchable_index_col (tsvector) will be added by migration for Postgres only
CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
""")
# Define FTS5 virtual table creation for SQLite only # Define FTS5 virtual table creation for SQLite only
# This DDL is executed separately for SQLite databases # This DDL is executed separately for SQLite databases
@@ -1,7 +1,8 @@
"""Repository for managing entities in the knowledge graph.""" """Repository for managing entities in the knowledge graph."""
from pathlib import Path from pathlib import Path
from typing import List, Optional, Sequence, Union from typing import List, Optional, Sequence, Union, Any
from loguru import logger from loguru import logger
from sqlalchemy import select from sqlalchemy import select
@@ -9,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from sqlalchemy.engine import Row
from basic_memory import db from basic_memory import db
from basic_memory.models.knowledge import Entity, Observation, Relation from basic_memory.models.knowledge import Entity, Observation, Relation
@@ -31,6 +33,18 @@ class EntityRepository(Repository[Entity]):
""" """
super().__init__(session_maker, Entity, project_id=project_id) super().__init__(session_maker, Entity, project_id=project_id)
async def get_by_id(self, entity_id: int) -> Optional[Entity]:
"""Get entity by numeric ID.
Args:
entity_id: Numeric entity ID
Returns:
Entity if found, None otherwise
"""
async with db.scoped_session(self.session_maker) as session:
return await self.select_by_id(session, entity_id)
async def get_by_permalink(self, permalink: str) -> Optional[Entity]: async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
"""Get entity by permalink. """Get entity by permalink.
@@ -63,6 +77,127 @@ class EntityRepository(Repository[Entity]):
) )
return await self.find_one(query) return await self.find_one(query)
# -------------------------------------------------------------------------
# Lightweight methods for permalink resolution (no eager loading)
# -------------------------------------------------------------------------
async def permalink_exists(self, permalink: str) -> bool:
"""Check if a permalink exists without loading the full entity.
This is much faster than get_by_permalink() as it skips eager loading
of observations and relations. Use for existence checks in bulk operations.
Args:
permalink: Permalink to check
Returns:
True if permalink exists, False otherwise
"""
query = select(Entity.id).where(Entity.permalink == permalink).limit(1)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return result.scalar_one_or_none() is not None
async def get_file_path_for_permalink(self, permalink: str) -> Optional[str]:
"""Get the file_path for a permalink without loading the full entity.
Use when you only need the file_path, not the full entity with relations.
Args:
permalink: Permalink to look up
Returns:
file_path string if found, None otherwise
"""
query = select(Entity.file_path).where(Entity.permalink == permalink)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return result.scalar_one_or_none()
async def get_permalink_for_file_path(self, file_path: Union[Path, str]) -> Optional[str]:
"""Get the permalink for a file_path without loading the full entity.
Use when you only need the permalink, not the full entity with relations.
Args:
file_path: File path to look up
Returns:
permalink string if found, None otherwise
"""
query = select(Entity.permalink).where(Entity.file_path == Path(file_path).as_posix())
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return result.scalar_one_or_none()
async def get_all_permalinks(self) -> List[str]:
"""Get all permalinks for this project.
Optimized for bulk operations - returns only permalink strings
without loading entities or relationships.
Returns:
List of all permalinks in the project
"""
query = select(Entity.permalink)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_permalink_to_file_path_map(self) -> dict[str, str]:
"""Get a mapping of permalink -> file_path for all entities.
Optimized for bulk permalink resolution - loads minimal data in one query.
Returns:
Dict mapping permalink to file_path
"""
query = select(Entity.permalink, Entity.file_path)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return {row.permalink: row.file_path for row in result.all()}
async def get_file_path_to_permalink_map(self) -> dict[str, str]:
"""Get a mapping of file_path -> permalink for all entities.
Optimized for bulk permalink resolution - loads minimal data in one query.
Returns:
Dict mapping file_path to permalink
"""
query = select(Entity.file_path, Entity.permalink)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return {row.file_path: row.permalink for row in result.all()}
async def get_by_file_paths(
self, session: AsyncSession, file_paths: Sequence[Union[Path, str]]
) -> List[Row[Any]]:
"""Get file paths and checksums for multiple entities (optimized for change detection).
Only queries file_path and checksum columns, skips loading full entities and relationships.
This is much faster than loading complete Entity objects when you only need checksums.
Args:
session: Database session to use for the query
file_paths: List of file paths to query
Returns:
List of (file_path, checksum) tuples for matching entities
"""
if not file_paths:
return []
# Convert all paths to POSIX strings for consistent comparison
posix_paths = [Path(fp).as_posix() for fp in file_paths]
# Query ONLY file_path and checksum columns (not full Entity objects)
query = select(Entity.file_path, Entity.checksum).where(Entity.file_path.in_(posix_paths))
query = self._add_project_filter(query)
result = await session.execute(query)
return list(result.all())
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]: async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
"""Find entities with the given checksum. """Find entities with the given checksum.
@@ -80,6 +215,34 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False) result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all()) return list(result.scalars().all())
async def find_by_checksums(self, checksums: Sequence[str]) -> Sequence[Entity]:
"""Find entities with any of the given checksums (batch query for move detection).
This is a batch-optimized version of find_by_checksum() that queries multiple checksums
in a single database query. Used for efficient move detection in cloud indexing.
Performance: For 1000 new files, this makes 1 query vs 1000 individual queries (~100x faster).
Example:
When processing new files, we check if any are actually moved files by finding
entities with matching checksums at different paths.
Args:
checksums: List of file content checksums to search for
Returns:
Sequence of entities with matching checksums (may be empty).
Multiple entities may have the same checksum if files were copied.
"""
if not checksums:
return []
# Query: SELECT * FROM entities WHERE checksum IN (checksum1, checksum2, ...)
query = self.select().where(Entity.checksum.in_(checksums))
# Don't load relationships for move detection - we only need file_path and checksum
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool: async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
"""Delete entity with the provided file_path. """Delete entity with the provided file_path.
@@ -2,6 +2,7 @@
from typing import Dict, List, Sequence from typing import Dict, List, Sequence
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -5,6 +5,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from loguru import logger from loguru import logger
from sqlalchemy import text from sqlalchemy import text
@@ -257,7 +258,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
{score_expr} as score {score_expr} as score
FROM search_index FROM search_index
WHERE {where_clause} WHERE {where_clause}
ORDER BY score DESC {order_by_clause} ORDER BY score DESC, id ASC {order_by_clause}
LIMIT :limit LIMIT :limit
OFFSET :offset OFFSET :offset
""" """
@@ -311,3 +312,68 @@ class PostgresSearchRepository(SearchRepositoryBase):
) )
return results return results
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
"""Index multiple items in a single batch operation using UPSERT.
Uses INSERT ... ON CONFLICT DO UPDATE to handle re-indexing of existing
entities (e.g., during forward reference resolution) without requiring
a separate delete operation. This eliminates race conditions between
delete and insert operations in separate transactions.
Args:
search_index_rows: List of SearchIndexRow objects to index
"""
if not search_index_rows:
return
async with db.scoped_session(self.session_maker) as session:
# When using text() raw SQL, always serialize JSON to string
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
# The database driver/column type will handle conversion
insert_data_list = []
for row in search_index_rows:
insert_data = row.to_insert(serialize_json=True)
insert_data["project_id"] = self.project_id
insert_data_list.append(insert_data)
# Use UPSERT (INSERT ... ON CONFLICT) to handle re-indexing
# Primary key is (id, type, project_id)
# This handles race conditions during forward reference resolution
# where an entity might be re-indexed before the delete commits
# Syntax works for both SQLite 3.24+ and PostgreSQL
await session.execute(
text("""
INSERT INTO search_index (
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at,
project_id
) VALUES (
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at,
:project_id
)
ON CONFLICT (id, type, project_id) DO UPDATE SET
title = EXCLUDED.title,
content_stems = EXCLUDED.content_stems,
content_snippet = EXCLUDED.content_snippet,
permalink = EXCLUDED.permalink,
file_path = EXCLUDED.file_path,
metadata = EXCLUDED.metadata,
from_id = EXCLUDED.from_id,
to_id = EXCLUDED.to_id,
relation_type = EXCLUDED.relation_type,
entity_id = EXCLUDED.entity_id,
category = EXCLUDED.category,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at
"""),
insert_data_list,
)
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
await session.commit()
@@ -3,6 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Optional, Sequence, Union from typing import Optional, Sequence, Union
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -23,7 +24,7 @@ class ProjectRepository(Repository[Project]):
super().__init__(session_maker, Project) super().__init__(session_maker, Project)
async def get_by_name(self, name: str) -> Optional[Project]: async def get_by_name(self, name: str) -> Optional[Project]:
"""Get project by name. """Get project by name (exact match).
Args: Args:
name: Unique name of the project name: Unique name of the project
@@ -31,6 +32,18 @@ class ProjectRepository(Repository[Project]):
query = self.select().where(Project.name == name) query = self.select().where(Project.name == name)
return await self.find_one(query) 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]: async def get_by_permalink(self, permalink: str) -> Optional[Project]:
"""Get project by permalink. """Get project by permalink.
@@ -49,6 +62,18 @@ class ProjectRepository(Repository[Project]):
query = self.select().where(Project.path == Path(path).as_posix()) query = self.select().where(Project.path == Path(path).as_posix())
return await self.find_one(query) return await self.find_one(query)
async def get_by_id(self, project_id: int) -> Optional[Project]:
"""Get project by numeric ID.
Args:
project_id: Numeric project ID
Returns:
Project if found, None otherwise
"""
async with db.scoped_session(self.session_maker) as session:
return await self.select_by_id(session, project_id)
async def get_default_project(self) -> Optional[Project]: async def get_default_project(self) -> Optional[Project]:
"""Get the default project (the one marked as is_default=True).""" """Get the default project (the one marked as is_default=True)."""
query = self.select().where(Project.is_default.is_not(None)) query = self.select().where(Project.is_default.is_not(None))
@@ -1,9 +1,11 @@
"""Repository for managing Relation objects.""" """Repository for managing Relation objects."""
from sqlalchemy import and_, delete
from typing import Sequence, List, Optional from typing import Sequence, List, Optional
from sqlalchemy import select
from sqlalchemy import and_, delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload, aliased from sqlalchemy.orm import selectinload, aliased
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@@ -86,5 +88,59 @@ class RelationRepository(Repository[Relation]):
result = await self.execute_query(query) result = await self.execute_query(query)
return result.scalars().all() return result.scalars().all()
async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int:
"""Bulk insert relations, ignoring duplicates.
Uses ON CONFLICT DO NOTHING to skip relations that would violate the
unique constraint on (from_id, to_name, relation_type). This is useful
for bulk operations where the same link may appear multiple times in
a document.
Works with both SQLite and PostgreSQL dialects.
Args:
relations: List of Relation objects to insert
Returns:
Number of relations actually inserted (excludes duplicates)
"""
if not relations:
return 0
# Convert Relation objects to dicts for insert
values = [
{
"project_id": r.project_id if r.project_id else self.project_id,
"from_id": r.from_id,
"to_id": r.to_id,
"to_name": r.to_name,
"relation_type": r.relation_type,
"context": r.context,
}
for r in relations
]
async with db.scoped_session(self.session_maker) as session:
# Check dialect to use appropriate insert
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
if dialect_name == "postgresql":
# PostgreSQL: use RETURNING to count inserted rows
# (rowcount is 0 for ON CONFLICT DO NOTHING)
stmt = (
pg_insert(Relation)
.values(values)
.on_conflict_do_nothing()
.returning(Relation.id)
)
result = await session.execute(stmt)
return len(result.fetchall())
else:
# SQLite: rowcount works correctly
stmt = sqlite_insert(Relation).values(values)
stmt = stmt.on_conflict_do_nothing()
result = await session.execute(stmt)
return result.rowcount if result.rowcount > 0 else 0
def get_load_options(self) -> List[LoaderOption]: def get_load_options(self) -> List[LoaderOption]:
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)] return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
@@ -2,6 +2,7 @@
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
from loguru import logger from loguru import logger
from sqlalchemy import ( from sqlalchemy import (
select, select,
@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from loguru import logger from loguru import logger
from sqlalchemy import Executable, Result, text from sqlalchemy import Executable, Result, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -5,6 +5,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from loguru import logger from loguru import logger
from sqlalchemy import text from sqlalchemy import text
+1 -1
View File
@@ -183,7 +183,7 @@ ObservationStr = Annotated[
str, str,
BeforeValidator(str.strip), # Clean whitespace BeforeValidator(str.strip), # Clean whitespace
MinLen(1), # Ensure non-empty after stripping MinLen(1), # Ensure non-empty after stripping
MaxLen(1000), # Keep reasonable length # No MaxLen - matches DB Text column which has no length restriction
] ]
+7
View File
@@ -124,6 +124,7 @@ class EntitySummary(BaseModel):
"""Simplified entity representation.""" """Simplified entity representation."""
type: Literal["entity"] = "entity" type: Literal["entity"] = "entity"
entity_id: int # Database ID for v2 API consistency
permalink: Optional[str] permalink: Optional[str]
title: str title: str
content: Optional[str] = None content: Optional[str] = None
@@ -141,12 +142,16 @@ class RelationSummary(BaseModel):
"""Simplified relation representation.""" """Simplified relation representation."""
type: Literal["relation"] = "relation" type: Literal["relation"] = "relation"
relation_id: int # Database ID for v2 API consistency
entity_id: Optional[int] = None # ID of the entity this relation belongs to
title: str title: str
file_path: str file_path: str
permalink: str permalink: str
relation_type: str relation_type: str
from_entity: Optional[str] = None from_entity: Optional[str] = None
from_entity_id: Optional[int] = None # ID of source entity
to_entity: Optional[str] = None to_entity: Optional[str] = None
to_entity_id: Optional[int] = None # ID of target entity
created_at: Annotated[ created_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"}) datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
] ]
@@ -160,6 +165,8 @@ class ObservationSummary(BaseModel):
"""Simplified observation representation.""" """Simplified observation representation."""
type: Literal["observation"] = "observation" type: Literal["observation"] = "observation"
observation_id: int # Database ID for v2 API consistency
entity_id: Optional[int] = None # ID of the entity this observation belongs to
title: str title: str
file_path: str file_path: str
permalink: str permalink: str
+1
View File
@@ -173,6 +173,7 @@ class ProjectWatchStatus(BaseModel):
class ProjectItem(BaseModel): class ProjectItem(BaseModel):
"""Simple representation of a project.""" """Simple representation of a project."""
id: int
name: str name: str
path: str path: str
is_default: bool = False is_default: bool = False
+5
View File
@@ -97,6 +97,11 @@ class SearchResult(BaseModel):
metadata: Optional[dict] = None metadata: Optional[dict] = None
# IDs for v2 API consistency
entity_id: Optional[int] = None # Entity ID (always present for entities)
observation_id: Optional[int] = None # Observation ID (for observation results)
relation_id: Optional[int] = None # Relation ID (for relation results)
# Type-specific fields # Type-specific fields
category: Optional[str] = None # For observations category: Optional[str] = None # For observations
from_entity: Optional[Permalink] = None # For relations from_entity: Optional[Permalink] = None # For relations
+27
View File
@@ -0,0 +1,27 @@
"""V2 API schemas - ID-based entity and project references."""
from basic_memory.schemas.v2.entity import (
EntityResolveRequest,
EntityResolveResponse,
EntityResponseV2,
MoveEntityRequestV2,
ProjectResolveRequest,
ProjectResolveResponse,
)
from basic_memory.schemas.v2.resource import (
CreateResourceRequest,
UpdateResourceRequest,
ResourceResponse,
)
__all__ = [
"EntityResolveRequest",
"EntityResolveResponse",
"EntityResponseV2",
"MoveEntityRequestV2",
"ProjectResolveRequest",
"ProjectResolveResponse",
"CreateResourceRequest",
"UpdateResourceRequest",
"ResourceResponse",
]
+129
View File
@@ -0,0 +1,129 @@
"""V2 entity and project schemas with ID-first design."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field, ConfigDict
from basic_memory.schemas.response import ObservationResponse, RelationResponse
class EntityResolveRequest(BaseModel):
"""Request to resolve a string identifier to an entity ID.
Supports resolution of:
- Permalinks (e.g., "specs/search")
- Titles (e.g., "Search Specification")
- File paths (e.g., "specs/search.md")
"""
identifier: str = Field(
...,
description="Entity identifier to resolve (permalink, title, or file path)",
min_length=1,
max_length=500,
)
class EntityResolveResponse(BaseModel):
"""Response from identifier resolution.
Returns the entity ID and associated metadata for the resolved entity.
"""
entity_id: int = Field(..., description="Numeric entity ID (primary identifier)")
permalink: Optional[str] = Field(None, description="Entity permalink")
file_path: str = Field(..., description="Relative file path")
title: str = Field(..., description="Entity title")
resolution_method: Literal["id", "permalink", "title", "path", "search"] = Field(
..., description="How the identifier was resolved"
)
class MoveEntityRequestV2(BaseModel):
"""V2 request schema for moving an entity to a new file location.
In V2 API, the entity ID is provided in the URL path, so this request
only needs the destination path.
"""
destination_path: str = Field(
...,
description="New file path for the entity (relative to project root)",
min_length=1,
max_length=500,
)
class EntityResponseV2(BaseModel):
"""V2 entity response with ID as the primary field.
This response format emphasizes the entity ID as the primary identifier,
with all other fields (permalink, file_path) as secondary metadata.
"""
# ID first - this is the primary identifier in v2
id: int = Field(..., description="Numeric entity ID (primary identifier)")
# Core entity fields
title: str = Field(..., description="Entity title")
entity_type: str = Field(..., description="Entity type")
content_type: str = Field(default="text/markdown", description="Content MIME type")
# Secondary identifiers (for compatibility and convenience)
permalink: Optional[str] = Field(None, description="Entity permalink (may change)")
file_path: str = Field(..., description="Relative file path (may change)")
# Content and metadata
content: Optional[str] = Field(None, description="Entity content")
entity_metadata: Optional[Dict] = Field(None, description="Entity metadata")
# Relationships
observations: List[ObservationResponse] = Field(
default_factory=list, description="Entity observations"
)
relations: List[RelationResponse] = Field(default_factory=list, description="Entity relations")
# Timestamps
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
# V2-specific metadata
api_version: Literal["v2"] = Field(
default="v2", description="API version (always 'v2' for this response)"
)
model_config = ConfigDict(from_attributes=True)
class ProjectResolveRequest(BaseModel):
"""Request to resolve a project identifier to a project ID.
Supports resolution of:
- Project names (e.g., "my-project")
- Permalinks (e.g., "my-project")
"""
identifier: str = Field(
...,
description="Project identifier to resolve (name or permalink)",
min_length=1,
max_length=255,
)
class ProjectResolveResponse(BaseModel):
"""Response from project identifier resolution.
Returns the project ID and associated metadata for the resolved project.
"""
project_id: int = Field(..., description="Numeric project ID (primary identifier)")
name: str = Field(..., description="Project name")
permalink: str = Field(..., description="Project permalink")
path: str = Field(..., description="Project file path")
is_active: bool = Field(..., description="Whether the project is active")
is_default: bool = Field(..., description="Whether the project is the default")
resolution_method: Literal["id", "name", "permalink"] = Field(
..., description="How the identifier was resolved"
)
+46
View File
@@ -0,0 +1,46 @@
"""V2 resource schemas for file content operations."""
from pydantic import BaseModel, Field
class CreateResourceRequest(BaseModel):
"""Request to create a new resource file.
File path is required for new resources since we need to know where
to create the file.
"""
file_path: str = Field(
...,
description="Path to create the file, relative to project root",
min_length=1,
max_length=500,
)
content: str = Field(..., description="File content to write")
class UpdateResourceRequest(BaseModel):
"""Request to update an existing resource by entity ID.
Only content is required - the file path is already known from the entity.
Optionally can update the file_path to move the file.
"""
content: str = Field(..., description="File content to write")
file_path: str | None = Field(
None,
description="Optional new file path to move the resource",
min_length=1,
max_length=500,
)
class ResourceResponse(BaseModel):
"""Response from resource operations."""
entity_id: int = Field(..., description="Entity ID of the resource")
file_path: str = Field(..., description="File path of the resource")
checksum: str = Field(..., description="File content checksum")
size: int = Field(..., description="File size in bytes")
created_at: float = Field(..., description="Creation timestamp")
modified_at: float = Field(..., description="Modification timestamp")
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from loguru import logger from loguru import logger
from sqlalchemy import text from sqlalchemy import text
+15 -2
View File
@@ -3,8 +3,10 @@
import fnmatch import fnmatch
import logging import logging
import os import os
from datetime import datetime
from typing import Dict, List, Optional, Sequence from typing import Dict, List, Optional, Sequence
from basic_memory.models import Entity from basic_memory.models import Entity
from basic_memory.repository import EntityRepository from basic_memory.repository import EntityRepository
from basic_memory.schemas.directory import DirectoryNode from basic_memory.schemas.directory import DirectoryNode
@@ -12,6 +14,17 @@ from basic_memory.schemas.directory import DirectoryNode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _mtime_to_datetime(entity: Entity) -> datetime:
"""Convert entity mtime (file modification time) to datetime.
Returns the file's actual modification time, falling back to updated_at
if mtime is not available.
"""
if entity.mtime:
return datetime.fromtimestamp(entity.mtime).astimezone()
return entity.updated_at
class DirectoryService: class DirectoryService:
"""Service for working with directory trees.""" """Service for working with directory trees."""
@@ -77,7 +90,7 @@ class DirectoryService:
entity_id=file.id, entity_id=file.id,
entity_type=file.entity_type, entity_type=file.entity_type,
content_type=file.content_type, content_type=file.content_type,
updated_at=file.updated_at, updated_at=_mtime_to_datetime(file),
) )
# Add to parent directory's children # Add to parent directory's children
@@ -241,7 +254,7 @@ class DirectoryService:
entity_id=file.id, entity_id=file.id,
entity_type=file.entity_type, entity_type=file.entity_type,
content_type=file.content_type, content_type=file.content_type,
updated_at=file.updated_at, updated_at=_mtime_to_datetime(file),
) )
# Add to parent directory's children # Add to parent directory's children
+68 -33
View File
@@ -8,6 +8,7 @@ import yaml
from loguru import logger from loguru import logger
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.file_utils import ( from basic_memory.file_utils import (
has_frontmatter, has_frontmatter,
@@ -28,6 +29,7 @@ from basic_memory.schemas.base import Permalink
from basic_memory.services import BaseService, FileService from basic_memory.services import BaseService, FileService
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
from basic_memory.services.link_resolver import LinkResolver from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.utils import generate_permalink from basic_memory.utils import generate_permalink
@@ -42,6 +44,7 @@ class EntityService(BaseService[EntityModel]):
relation_repository: RelationRepository, relation_repository: RelationRepository,
file_service: FileService, file_service: FileService,
link_resolver: LinkResolver, link_resolver: LinkResolver,
search_service: Optional[SearchService] = None,
app_config: Optional[BasicMemoryConfig] = None, app_config: Optional[BasicMemoryConfig] = None,
): ):
super().__init__(entity_repository) super().__init__(entity_repository)
@@ -50,6 +53,7 @@ class EntityService(BaseService[EntityModel]):
self.entity_parser = entity_parser self.entity_parser = entity_parser
self.file_service = file_service self.file_service = file_service
self.link_resolver = link_resolver self.link_resolver = link_resolver
self.search_service = search_service
self.app_config = app_config self.app_config = app_config
async def detect_file_path_conflicts( async def detect_file_path_conflicts(
@@ -106,6 +110,9 @@ class EntityService(BaseService[EntityModel]):
4. Generate new unique permalink from file path 4. Generate new unique permalink from file path
Enhanced to detect and handle character-related conflicts. Enhanced to detect and handle character-related conflicts.
Note: Uses lightweight repository methods that skip eager loading of
observations and relations for better performance during bulk operations.
""" """
file_path_str = Path(file_path).as_posix() file_path_str = Path(file_path).as_posix()
@@ -122,16 +129,20 @@ class EntityService(BaseService[EntityModel]):
# If markdown has explicit permalink, try to validate it # If markdown has explicit permalink, try to validate it
if markdown and markdown.frontmatter.permalink: if markdown and markdown.frontmatter.permalink:
desired_permalink = markdown.frontmatter.permalink desired_permalink = markdown.frontmatter.permalink
existing = await self.repository.get_by_permalink(desired_permalink) # Use lightweight method - we only need to check file_path
existing_file_path = await self.repository.get_file_path_for_permalink(
desired_permalink
)
# If no conflict or it's our own file, use as is # If no conflict or it's our own file, use as is
if not existing or existing.file_path == file_path_str: if not existing_file_path or existing_file_path == file_path_str:
return desired_permalink return desired_permalink
# For existing files, try to find current permalink # For existing files, try to find current permalink
existing = await self.repository.get_by_file_path(file_path_str) # Use lightweight method - we only need the permalink
if existing: existing_permalink = await self.repository.get_permalink_for_file_path(file_path_str)
return existing.permalink if existing_permalink:
return existing_permalink
# New file - generate permalink # New file - generate permalink
if markdown and markdown.frontmatter.permalink: if markdown and markdown.frontmatter.permalink:
@@ -140,9 +151,10 @@ class EntityService(BaseService[EntityModel]):
desired_permalink = generate_permalink(file_path_str) desired_permalink = generate_permalink(file_path_str)
# Make unique if needed - enhanced to handle character conflicts # Make unique if needed - enhanced to handle character conflicts
# Use lightweight existence check instead of loading full entity
permalink = desired_permalink permalink = desired_permalink
suffix = 1 suffix = 1
while await self.repository.get_by_permalink(permalink): while await self.repository.permalink_exists(permalink):
permalink = f"{desired_permalink}-{suffix}" permalink = f"{desired_permalink}-{suffix}"
suffix += 1 suffix += 1
logger.debug(f"creating unique permalink: {permalink}") logger.debug(f"creating unique permalink: {permalink}")
@@ -224,8 +236,11 @@ class EntityService(BaseService[EntityModel]):
final_content = dump_frontmatter(post) final_content = dump_frontmatter(post)
checksum = await self.file_service.write_file(file_path, final_content) checksum = await self.file_service.write_file(file_path, final_content)
# parse entity from file # parse entity from content we just wrote (avoids re-reading file for cloud compatibility)
entity_markdown = await self.entity_parser.parse_file(file_path) entity_markdown = await self.entity_parser.parse_markdown_content(
file_path=file_path,
content=final_content,
)
# create entity # create entity
created = await self.create_entity_from_markdown(file_path, entity_markdown) created = await self.create_entity_from_markdown(file_path, entity_markdown)
@@ -245,8 +260,12 @@ class EntityService(BaseService[EntityModel]):
# Convert file path string to Path # Convert file path string to Path
file_path = Path(entity.file_path) file_path = Path(entity.file_path)
# Read existing frontmatter from the file if it exists # Read existing content via file_service (for cloud compatibility)
existing_markdown = await self.entity_parser.parse_file(file_path) existing_content = await self.file_service.read_file_content(file_path)
existing_markdown = await self.entity_parser.parse_markdown_content(
file_path=file_path,
content=existing_content,
)
# Parse content frontmatter to check for user-specified permalink and entity_type # Parse content frontmatter to check for user-specified permalink and entity_type
content_markdown = None content_markdown = None
@@ -302,8 +321,11 @@ class EntityService(BaseService[EntityModel]):
final_content = dump_frontmatter(merged_post) final_content = dump_frontmatter(merged_post)
checksum = await self.file_service.write_file(file_path, final_content) checksum = await self.file_service.write_file(file_path, final_content)
# parse entity from file # parse entity from content we just wrote (avoids re-reading file for cloud compatibility)
entity_markdown = await self.entity_parser.parse_file(file_path) entity_markdown = await self.entity_parser.parse_markdown_content(
file_path=file_path,
content=final_content,
)
# update entity in db # update entity in db
entity = await self.update_entity_and_observations(file_path, entity_markdown) entity = await self.update_entity_and_observations(file_path, entity_markdown)
@@ -335,7 +357,11 @@ class EntityService(BaseService[EntityModel]):
) )
entity = entities[0] 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) await self.file_service.delete_entity_file(entity)
# Delete from DB (this will cascade to observations/relations) # Delete from DB (this will cascade to observations/relations)
@@ -378,7 +404,9 @@ class EntityService(BaseService[EntityModel]):
Uses UPSERT approach to handle permalink/file_path conflicts cleanly. Uses UPSERT approach to handle permalink/file_path conflicts cleanly.
""" """
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}") logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
model = entity_model_from_markdown(file_path, markdown) model = entity_model_from_markdown(
file_path, markdown, project_id=self.repository.project_id
)
# Mark as incomplete because we still need to add relations # Mark as incomplete because we still need to add relations
model.checksum = None model.checksum = None
@@ -408,6 +436,7 @@ class EntityService(BaseService[EntityModel]):
# add new observations # add new observations
observations = [ observations = [
Observation( Observation(
project_id=self.observation_repository.project_id,
entity_id=db_entity.id, entity_id=db_entity.id,
content=obs.content, content=obs.content,
category=obs.category, category=obs.category,
@@ -448,8 +477,11 @@ class EntityService(BaseService[EntityModel]):
import asyncio import asyncio
# Create tasks for all relation lookups # Create tasks for all relation lookups
# Use strict=True to disable fuzzy search - only exact matches should create resolved relations
# This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
lookup_tasks = [ lookup_tasks = [
self.link_resolver.resolve_link(rel.target) for rel in markdown.relations self.link_resolver.resolve_link(rel.target, strict=True)
for rel in markdown.relations
] ]
# Execute all lookups in parallel # Execute all lookups in parallel
@@ -471,6 +503,7 @@ class EntityService(BaseService[EntityModel]):
# Create the relation # Create the relation
relation = Relation( relation = Relation(
project_id=self.relation_repository.project_id,
from_id=db_entity.id, from_id=db_entity.id,
to_id=target_id, to_id=target_id,
to_name=target_name, to_name=target_name,
@@ -543,8 +576,11 @@ class EntityService(BaseService[EntityModel]):
# Write the updated content back to the file # Write the updated content back to the file
checksum = await self.file_service.write_file(file_path, new_content) checksum = await self.file_service.write_file(file_path, new_content)
# Parse the updated file to get new observations/relations # Parse the content we just wrote (avoids re-reading file for cloud compatibility)
entity_markdown = await self.entity_parser.parse_file(file_path) entity_markdown = await self.entity_parser.parse_markdown_content(
file_path=file_path,
content=new_content,
)
# Update entity and its relationships # Update entity and its relationships
entity = await self.update_entity_and_observations(file_path, entity_markdown) entity = await self.update_entity_and_observations(file_path, entity_markdown)
@@ -763,23 +799,20 @@ class EntityService(BaseService[EntityModel]):
raise ValueError(f"Invalid destination path: {destination_path}") raise ValueError(f"Invalid destination path: {destination_path}")
# 3. Validate paths # 3. Validate paths
source_file = project_config.home / current_path # NOTE: In tenantless/cloud mode, we cannot rely on local filesystem paths.
destination_file = project_config.home / destination_path # Use FileService for existence checks and moving.
if not await self.file_service.exists(current_path):
# Validate source exists
if not source_file.exists():
raise ValueError(f"Source file not found: {current_path}") raise ValueError(f"Source file not found: {current_path}")
# Check if destination already exists if await self.file_service.exists(destination_path):
if destination_file.exists():
raise ValueError(f"Destination already exists: {destination_path}") raise ValueError(f"Destination already exists: {destination_path}")
try: try:
# 4. Create destination directory if needed # 4. Ensure destination directory if needed (no-op for S3)
destination_file.parent.mkdir(parents=True, exist_ok=True) await self.file_service.ensure_directory(Path(destination_path).parent)
# 5. Move physical file # 5. Move physical file via FileService (filesystem rename or cloud move)
source_file.rename(destination_file) await self.file_service.move_file(current_path, destination_path)
logger.info(f"Moved file: {current_path} -> {destination_path}") logger.info(f"Moved file: {current_path} -> {destination_path}")
# 6. Prepare database updates # 6. Prepare database updates
@@ -818,12 +851,14 @@ class EntityService(BaseService[EntityModel]):
except Exception as e: except Exception as e:
# Rollback: try to restore original file location if move succeeded # Rollback: try to restore original file location if move succeeded
if destination_file.exists() and not source_file.exists(): try:
try: if await self.file_service.exists(
destination_file.rename(source_file) destination_path
) and not await self.file_service.exists(current_path):
await self.file_service.move_file(destination_path, current_path)
logger.info(f"Rolled back file move: {destination_path} -> {current_path}") logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
except Exception as rollback_error: # pragma: no cover except Exception as rollback_error: # pragma: no cover
logger.error(f"Failed to rollback file move: {rollback_error}") logger.error(f"Failed to rollback file move: {rollback_error}")
# Re-raise the original error with context # Re-raise the original error with context
raise ValueError(f"Move failed: {str(e)}") from e raise ValueError(f"Move failed: {str(e)}") from e
+123 -12
View File
@@ -3,15 +3,19 @@
import asyncio import asyncio
import hashlib import hashlib
import mimetypes import mimetypes
from os import stat_result from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Tuple, Union from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
import aiofiles import aiofiles
import yaml import yaml
from basic_memory import file_utils from basic_memory import file_utils
from basic_memory.file_utils import FileError, ParseError
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.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Entity as EntityModel from basic_memory.models import Entity as EntityModel
from basic_memory.schemas import Entity as EntitySchema from basic_memory.schemas import Entity as EntitySchema
@@ -41,9 +45,11 @@ class FileService:
base_path: Path, base_path: Path,
markdown_processor: MarkdownProcessor, markdown_processor: MarkdownProcessor,
max_concurrent_files: int = 10, max_concurrent_files: int = 10,
app_config: Optional["BasicMemoryConfig"] = None,
): ):
self.base_path = base_path.resolve() # Get absolute path self.base_path = base_path.resolve() # Get absolute path
self.markdown_processor = markdown_processor self.markdown_processor = markdown_processor
self.app_config = app_config
# Semaphore to limit concurrent file operations # Semaphore to limit concurrent file operations
# Prevents OOM on large projects by processing files in batches # Prevents OOM on large projects by processing files in batches
self._file_semaphore = asyncio.Semaphore(max_concurrent_files) self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
@@ -148,12 +154,15 @@ class FileService:
Handles both absolute and relative paths. Relative paths are resolved Handles both absolute and relative paths. Relative paths are resolved
against base_path. 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: Args:
path: Where to write (Path or string) path: Where to write (Path or string)
content: Content to write content: Content to write
Returns: Returns:
Checksum of written content Checksum of written content (or formatted content if formatting enabled)
Raises: Raises:
FileOperationError: If write fails FileOperationError: If write fails
@@ -176,8 +185,17 @@ class FileService:
await file_utils.write_file_atomic(full_path, content) await file_utils.write_file_atomic(full_path, content)
# Compute and return checksum # Format file if configured
checksum = await file_utils.compute_checksum(content) 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=}") logger.debug(f"File write completed path={full_path}, {checksum=}")
return checksum return checksum
@@ -220,6 +238,41 @@ class FileService:
logger.exception("File read error", path=str(full_path), error=str(e)) logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}") raise FileOperationError(f"Failed to read file: {e}")
async def read_file_bytes(self, path: FilePath) -> bytes:
"""Read file content as bytes using true async I/O with aiofiles.
This method reads files in binary mode, suitable for non-text files
like images, PDFs, etc. For cloud compatibility with S3FileService.
Args:
path: Path to read (Path or string)
Returns:
File content as bytes
Raises:
FileOperationError: If read fails
"""
# Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
logger.debug("Reading file bytes", operation="read_file_bytes", path=str(full_path))
async with aiofiles.open(full_path, mode="rb") as f:
content = await f.read()
logger.debug(
"File read completed",
path=str(full_path),
content_length=len(content),
)
return content
except Exception as e:
logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}")
async def read_file(self, path: FilePath) -> Tuple[str, str]: async def read_file(self, path: FilePath) -> Tuple[str, str]:
"""Read file and compute checksum using true async I/O. """Read file and compute checksum using true async I/O.
@@ -276,6 +329,43 @@ class FileService:
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
full_path.unlink(missing_ok=True) full_path.unlink(missing_ok=True)
async def move_file(self, source: FilePath, destination: FilePath) -> None:
"""Move/rename a file from source to destination.
This method abstracts the underlying storage (filesystem vs cloud).
Default implementation uses atomic filesystem rename, but cloud-backed
implementations (e.g., S3) can override to copy+delete.
Args:
source: Source path (relative to base_path or absolute)
destination: Destination path (relative to base_path or absolute)
Raises:
FileOperationError: If the move fails
"""
# Convert strings to Paths and resolve relative paths against base_path
src_obj = self.base_path / source if isinstance(source, str) else source
dst_obj = self.base_path / destination if isinstance(destination, str) else destination
src_full = src_obj if src_obj.is_absolute() else self.base_path / src_obj
dst_full = dst_obj if dst_obj.is_absolute() else self.base_path / dst_obj
try:
# Ensure destination directory exists
await self.ensure_directory(dst_full.parent)
# Use semaphore for concurrency control and run blocking rename in executor
async with self._file_semaphore:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: src_full.rename(dst_full))
except Exception as e:
logger.exception(
"File move error",
source=str(src_full),
destination=str(dst_full),
error=str(e),
)
raise FileOperationError(f"Failed to move file {source} -> {destination}: {e}")
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str: async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
"""Update frontmatter fields in a file while preserving all content. """Update frontmatter fields in a file while preserving all content.
@@ -332,7 +422,17 @@ class FileService:
) )
await file_utils.write_file_atomic(full_path, final_content) 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: except Exception as e:
# Only log real errors (not YAML parsing, which is handled above) # Only log real errors (not YAML parsing, which is handled above)
@@ -381,20 +481,31 @@ class FileService:
logger.error("Failed to compute checksum", path=str(full_path), error=str(e)) logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
raise FileError(f"Failed to compute checksum for {path}: {e}") raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: FilePath) -> stat_result: async def get_file_metadata(self, path: FilePath) -> FileMetadata:
"""Return file stats for a given path. """Return file metadata for a given path.
This method is async to support cloud implementations (S3FileService)
where file metadata requires async operations (head_object).
Args: Args:
path: Path to the file (Path or string) path: Path to the file (Path or string)
Returns: Returns:
File statistics FileMetadata with size, created_at, and modified_at
""" """
# Convert string to Path if needed # Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
return full_path.stat() # Run blocking stat() in thread pool to maintain async compatibility
loop = asyncio.get_event_loop()
stat_result = await loop.run_in_executor(None, full_path.stat)
return FileMetadata(
size=stat_result.st_size,
created_at=datetime.fromtimestamp(stat_result.st_ctime).astimezone(),
modified_at=datetime.fromtimestamp(stat_result.st_mtime).astimezone(),
)
def content_type(self, path: FilePath) -> str: def content_type(self, path: FilePath) -> str:
"""Return content_type for a given path. """Return content_type for a given path.
+51 -26
View File
@@ -5,8 +5,11 @@ to ensure consistent application startup across all entry points.
""" """
import asyncio import asyncio
import os
import sys
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
from basic_memory import db from basic_memory import db
@@ -27,15 +30,12 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None:
Database migrations are now handled automatically when the database Database migrations are now handled automatically when the database
connection is first established via get_or_create_db(). connection is first established via get_or_create_db().
""" """
# Trigger database initialization and migrations by getting the database connection
try: try:
await db.get_or_create_db(app_config.database_path) await db.get_or_create_db(app_config.database_path)
logger.info("Database initialization completed") logger.info("Database initialization completed")
except Exception as e: except Exception as e:
logger.error(f"Error initializing database: {e}") logger.error(f"Error during database initialization: {e}")
# Allow application to continue - it might still work raise
# depending on what the error was, and will fail with a
# more specific error if the database is actually unusable
async def reconcile_projects_with_config(app_config: BasicMemoryConfig): async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
@@ -49,31 +49,29 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
""" """
logger.info("Reconciling projects from config with database...") 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( _, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM, db_type=db.DatabaseType.FILESYSTEM,
ensure_migrations=False,
) )
project_repository = ProjectRepository(session_maker) project_repository = ProjectRepository(session_maker)
# Import ProjectService here to avoid circular imports # Import ProjectService here to avoid circular imports
from basic_memory.services.project_service import ProjectService from basic_memory.services.project_service import ProjectService
# Create project service and synchronize projects
project_service = ProjectService(repository=project_repository)
try: try:
# Create project service and synchronize projects
project_service = ProjectService(repository=project_repository)
await project_service.synchronize_projects() await project_service.synchronize_projects()
logger.info("Projects successfully reconciled between config and database") logger.info("Projects successfully reconciled between config and database")
except Exception as e: except Exception as e:
# Log the error but continue with initialization
logger.error(f"Error during project synchronization: {e}") logger.error(f"Error during project synchronization: {e}")
logger.info("Continuing with initialization despite synchronization error") logger.info("Continuing with initialization despite synchronization error")
async def initialize_file_sync( async def initialize_file_sync(
app_config: BasicMemoryConfig, app_config: BasicMemoryConfig,
): ) -> None:
"""Initialize file synchronization services. This function starts the watch service and does not return """Initialize file synchronization services. This function starts the watch service and does not return
Args: Args:
@@ -82,15 +80,20 @@ async def initialize_file_sync(
Returns: Returns:
The watch service task that's monitoring file changes 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 # delay import
from basic_memory.sync import WatchService 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( _, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM, db_type=db.DatabaseType.FILESYSTEM,
ensure_migrations=False,
) )
project_repository = ProjectRepository(session_maker) project_repository = ProjectRepository(session_maker)
@@ -104,6 +107,12 @@ async def initialize_file_sync(
# Get active projects # Get active projects
active_projects = await project_repository.get_active_projects() active_projects = await project_repository.get_active_projects()
# Filter to constrained project if MCP server was started with --project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
active_projects = [p for p in active_projects if p.name == constrained_project]
logger.info(f"Background sync constrained to project: {constrained_project}")
# Start sync for all projects as background tasks (non-blocking) # Start sync for all projects as background tasks (non-blocking)
async def sync_project_background(project: Project): async def sync_project_background(project: Project):
"""Sync a single project in the background.""" """Sync a single project in the background."""
@@ -131,12 +140,10 @@ async def initialize_file_sync(
# Then start the watch service in the background # Then start the watch service in the background
logger.info("Starting watch service for all projects") logger.info("Starting watch service for all projects")
# run the watch service # run the watch service
try: await watch_service.run()
await watch_service.run() logger.info("Watch service started")
logger.info("Watch service started")
except Exception as e: # pragma: no cover
logger.error(f"Error starting watch service: {e}")
return None return None
@@ -155,6 +162,11 @@ async def initialize_app(
Args: Args:
app_config: The Basic Memory project configuration 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...") logger.info("Initializing app...")
# Initialize database first # Initialize database first
await initialize_database(app_config) await initialize_database(app_config)
@@ -181,11 +193,24 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
logger.debug("Skipping initialization in cloud mode - projects managed by cloud") logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
return return
try: async def _init_and_cleanup():
result = asyncio.run(initialize_app(app_config)) """Initialize app and clean up database connections.
logger.info(f"Initialization completed successfully: result={result}")
except Exception as e: # pragma: no cover Database connections created during initialization must be cleaned up
logger.exception(f"Error during initialization: {e}") before the event loop closes, otherwise the process will hang indefinitely.
# Continue execution even if initialization fails """
# The command might still work, or will fail with a try:
# more specific error message await initialize_app(app_config)
finally:
# Always cleanup database connections to prevent process hang
await db.shutdown_db()
# On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
# The ProactorEventLoop can raise "IndexError: pop from an empty deque" during
# event loop cleanup when there are pending handles. SelectorEventLoop is more
# stable for our use case (no subprocess pipes or named pipes needed).
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(_init_and_cleanup())
logger.info("Initialization completed successfully")
@@ -2,6 +2,7 @@
from typing import Optional, Tuple from typing import Optional, Tuple
from loguru import logger from loguru import logger
from basic_memory.models import Entity from basic_memory.models import Entity
+6 -6
View File
@@ -8,6 +8,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Sequence from typing import Dict, Optional, Sequence
from loguru import logger from loguru import logger
from sqlalchemy import text from sqlalchemy import text
@@ -23,9 +24,6 @@ from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_co
from basic_memory.utils import generate_permalink from basic_memory.utils import generate_permalink
config = ConfigManager().config
class ProjectService: class ProjectService:
"""Service for managing Basic Memory projects.""" """Service for managing Basic Memory projects."""
@@ -143,6 +141,7 @@ class ProjectService:
""" """
# If project_root is set, constrain all projects to that directory # If project_root is set, constrain all projects to that directory
project_root = self.config_manager.config.project_root project_root = self.config_manager.config.project_root
sanitized_name = None
if project_root: if project_root:
base_path = Path(project_root) base_path = Path(project_root)
@@ -199,14 +198,15 @@ class ProjectService:
f"Projects cannot share directory trees." f"Projects cannot share directory trees."
) )
# First add to config file (this will validate the project doesn't exist) if not self.config_manager.config.cloud_mode:
project_config = self.config_manager.add_project(name, resolved_path) # First add to config file (this will validate the project doesn't exist)
self.config_manager.add_project(name, resolved_path)
# Then add to database # Then add to database
project_data = { project_data = {
"name": name, "name": name,
"path": resolved_path, "path": resolved_path,
"permalink": generate_permalink(project_config.name), "permalink": sanitized_name,
"is_active": True, "is_active": True,
# Don't set is_default=False to avoid UNIQUE constraint issues # Don't set is_default=False to avoid UNIQUE constraint issues
# Let it default to NULL, only set to True when explicitly making default # Let it default to NULL, only set to True when explicitly making default
+50 -10
View File
@@ -4,6 +4,7 @@ import ast
from datetime import datetime from datetime import datetime
from typing import List, Optional, Set from typing import List, Optional, Set
from dateparser import parse from dateparser import parse
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from loguru import logger from loguru import logger
@@ -15,6 +16,21 @@ from basic_memory.repository.search_repository import SearchRepository, SearchIn
from basic_memory.schemas.search import SearchQuery, SearchItemType from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services import FileService from basic_memory.services import FileService
# Maximum size for content_stems field to stay under Postgres's 8KB index row limit.
# We use 6000 characters to leave headroom for other indexed columns and overhead.
MAX_CONTENT_STEMS_SIZE = 6000
def _mtime_to_datetime(entity: Entity) -> datetime:
"""Convert entity mtime (file modification time) to datetime.
Returns the file's actual modification time, falling back to updated_at
if mtime is not available.
"""
if entity.mtime:
return datetime.fromtimestamp(entity.mtime).astimezone()
return entity.updated_at
class SearchService: class SearchService:
"""Service for search operations. """Service for search operations.
@@ -156,22 +172,24 @@ class SearchService:
self, self,
entity: Entity, entity: Entity,
background_tasks: Optional[BackgroundTasks] = None, background_tasks: Optional[BackgroundTasks] = None,
content: str | None = None,
) -> None: ) -> None:
if background_tasks: if background_tasks:
background_tasks.add_task(self.index_entity_data, entity) background_tasks.add_task(self.index_entity_data, entity, content)
else: else:
await self.index_entity_data(entity) await self.index_entity_data(entity, content)
async def index_entity_data( async def index_entity_data(
self, self,
entity: Entity, entity: Entity,
content: str | None = None,
) -> None: ) -> None:
# delete all search index data associated with entity # delete all search index data associated with entity
await self.repository.delete_by_entity_id(entity_id=entity.id) await self.repository.delete_by_entity_id(entity_id=entity.id)
# reindex # reindex
await self.index_entity_markdown( await self.index_entity_markdown(
entity entity, content
) if entity.is_markdown else await self.index_entity_file(entity) ) if entity.is_markdown else await self.index_entity_file(entity)
async def index_entity_file( async def index_entity_file(
@@ -191,7 +209,7 @@ class SearchService:
"entity_type": entity.entity_type, "entity_type": entity.entity_type,
}, },
created_at=entity.created_at, created_at=entity.created_at,
updated_at=entity.updated_at, updated_at=_mtime_to_datetime(entity),
project_id=entity.project_id, project_id=entity.project_id,
) )
) )
@@ -199,9 +217,14 @@ class SearchService:
async def index_entity_markdown( async def index_entity_markdown(
self, self,
entity: Entity, entity: Entity,
content: str | None = None,
) -> None: ) -> None:
"""Index an entity and all its observations and relations. """Index an entity and all its observations and relations.
Args:
entity: The entity to index
content: Optional pre-loaded content (avoids file read). If None, will read from file.
Indexing structure: Indexing structure:
1. Entities 1. Entities
- permalink: direct from entity (e.g., "specs/search") - permalink: direct from entity (e.g., "specs/search")
@@ -230,7 +253,9 @@ class SearchService:
title_variants = self._generate_variants(entity.title) title_variants = self._generate_variants(entity.title)
content_stems.extend(title_variants) content_stems.extend(title_variants)
content = await self.file_service.read_entity_content(entity) # Use provided content or read from file
if content is None:
content = await self.file_service.read_entity_content(entity)
if content: if content:
content_stems.append(content) content_stems.append(content)
content_snippet = f"{content[:250]}" content_snippet = f"{content[:250]}"
@@ -247,6 +272,10 @@ class SearchService:
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip()) entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
# Truncate to stay under Postgres's 8KB index row limit
if len(entity_content_stems) > MAX_CONTENT_STEMS_SIZE:
entity_content_stems = entity_content_stems[:MAX_CONTENT_STEMS_SIZE]
# Add entity row # Add entity row
rows_to_index.append( rows_to_index.append(
SearchIndexRow( SearchIndexRow(
@@ -262,17 +291,28 @@ class SearchService:
"entity_type": entity.entity_type, "entity_type": entity.entity_type,
}, },
created_at=entity.created_at, created_at=entity.created_at,
updated_at=entity.updated_at, updated_at=_mtime_to_datetime(entity),
project_id=entity.project_id, project_id=entity.project_id,
) )
) )
# Add observation rows # Add observation rows - dedupe by permalink to avoid unique constraint violations
# Two observations with same entity/category/content generate identical permalinks
seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
for obs in entity.observations: for obs in entity.observations:
obs_permalink = obs.permalink
if obs_permalink in seen_permalinks:
logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
continue
seen_permalinks.add(obs_permalink)
# Index with parent entity's file path since that's where it's defined # Index with parent entity's file path since that's where it's defined
obs_content_stems = "\n".join( obs_content_stems = "\n".join(
p for p in self._generate_variants(obs.content) if p and p.strip() p for p in self._generate_variants(obs.content) if p and p.strip()
) )
# Truncate to stay under Postgres's 8KB index row limit
if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE:
obs_content_stems = obs_content_stems[:MAX_CONTENT_STEMS_SIZE]
rows_to_index.append( rows_to_index.append(
SearchIndexRow( SearchIndexRow(
id=obs.id, id=obs.id,
@@ -280,7 +320,7 @@ class SearchService:
title=f"{obs.category}: {obs.content[:100]}...", title=f"{obs.category}: {obs.content[:100]}...",
content_stems=obs_content_stems, content_stems=obs_content_stems,
content_snippet=obs.content, content_snippet=obs.content,
permalink=obs.permalink, permalink=obs_permalink,
file_path=entity.file_path, file_path=entity.file_path,
category=obs.category, category=obs.category,
entity_id=entity.id, entity_id=entity.id,
@@ -288,7 +328,7 @@ class SearchService:
"tags": obs.tags, "tags": obs.tags,
}, },
created_at=entity.created_at, created_at=entity.created_at,
updated_at=entity.updated_at, updated_at=_mtime_to_datetime(entity),
project_id=entity.project_id, project_id=entity.project_id,
) )
) )
@@ -318,7 +358,7 @@ class SearchService:
to_id=rel.to_id, to_id=rel.to_id,
relation_type=rel.relation_type, relation_type=rel.relation_type,
created_at=entity.created_at, created_at=entity.created_at,
updated_at=entity.updated_at, updated_at=_mtime_to_datetime(entity),
project_id=entity.project_id, project_id=entity.project_id,
) )
) )
+121 -107
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import os import os
import sys
import time import time
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -10,7 +11,7 @@ from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Set, Tuple from typing import AsyncIterator, Dict, List, Optional, Set, Tuple
import aiofiles.os import aiofiles.os
import logfire
from loguru import logger from loguru import logger
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -215,17 +216,12 @@ class SyncService:
f"path={path}, error={error}" f"path={path}, error={error}"
) )
# Record metric for file failure
logfire.metric_counter("sync.circuit_breaker.failures").add(1)
# Log when threshold is reached # Log when threshold is reached
if failure_info.count >= MAX_CONSECUTIVE_FAILURES: if failure_info.count >= MAX_CONSECUTIVE_FAILURES:
logger.error( logger.error(
f"File {path} has failed {MAX_CONSECUTIVE_FAILURES} times and will be skipped. " f"File {path} has failed {MAX_CONSECUTIVE_FAILURES} times and will be skipped. "
f"First failure: {failure_info.first_failure}, Last error: {error}" f"First failure: {failure_info.first_failure}, Last error: {error}"
) )
# Record metric for file being blocked by circuit breaker
logfire.metric_counter("sync.circuit_breaker.blocked_files").add(1)
else: else:
# Create new failure record # Create new failure record
self._file_failures[path] = FileFailureInfo( self._file_failures[path] = FileFailureInfo(
@@ -255,7 +251,6 @@ class SyncService:
logger.info(f"Clearing failure history for {path} after successful sync") logger.info(f"Clearing failure history for {path} after successful sync")
del self._file_failures[path] del self._file_failures[path]
@logfire.instrument()
async def sync( async def sync(
self, directory: Path, project_name: Optional[str] = None, force_full: bool = False self, directory: Path, project_name: Optional[str] = None, force_full: bool = False
) -> SyncReport: ) -> SyncReport:
@@ -282,63 +277,58 @@ class SyncService:
) )
# sync moves first # sync moves first
with logfire.span("process_moves", move_count=len(report.moves)): for old_path, new_path in report.moves.items():
for old_path, new_path in report.moves.items(): # in the case where a file has been deleted and replaced by another file
# in the case where a file has been deleted and replaced by another file # it will show up in the move and modified lists, so handle it in modified
# it will show up in the move and modified lists, so handle it in modified if new_path in report.modified:
if new_path in report.modified: report.modified.remove(new_path)
report.modified.remove(new_path) logger.debug(
logger.debug( f"File marked as moved and modified: old_path={old_path}, new_path={new_path}"
f"File marked as moved and modified: old_path={old_path}, new_path={new_path}" )
) else:
else: await self.handle_move(old_path, new_path)
await self.handle_move(old_path, new_path)
# deleted next # deleted next
with logfire.span("process_deletes", delete_count=len(report.deleted)): for path in report.deleted:
for path in report.deleted: await self.handle_delete(path)
await self.handle_delete(path)
# then new and modified # then new and modified
with logfire.span("process_new_files", new_count=len(report.new)): for path in report.new:
for path in report.new: entity, _ = await self.sync_file(path, new=True)
entity, _ = await self.sync_file(path, new=True)
# Track if file was skipped # Track if file was skipped
if entity is None and await self._should_skip_file(path): if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path] failure_info = self._file_failures[path]
report.skipped_files.append( report.skipped_files.append(
SkippedFile( SkippedFile(
path=path, path=path,
reason=failure_info.last_error, reason=failure_info.last_error,
failure_count=failure_info.count, failure_count=failure_info.count,
first_failed=failure_info.first_failure, first_failed=failure_info.first_failure,
)
) )
)
with logfire.span("process_modified_files", modified_count=len(report.modified)): for path in report.modified:
for path in report.modified: entity, _ = await self.sync_file(path, new=False)
entity, _ = await self.sync_file(path, new=False)
# Track if file was skipped # Track if file was skipped
if entity is None and await self._should_skip_file(path): if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path] failure_info = self._file_failures[path]
report.skipped_files.append( report.skipped_files.append(
SkippedFile( SkippedFile(
path=path, path=path,
reason=failure_info.last_error, reason=failure_info.last_error,
failure_count=failure_info.count, failure_count=failure_info.count,
first_failed=failure_info.first_failure, first_failed=failure_info.first_failure,
)
) )
)
# Only resolve relations if there were actual changes # Only resolve relations if there were actual changes
# If no files changed, no new unresolved relations could have been created # If no files changed, no new unresolved relations could have been created
with logfire.span("resolve_relations"): if report.total > 0:
if report.total > 0: await self.resolve_relations()
await self.resolve_relations() else:
else: logger.info("Skipping relation resolution - no file changes detected")
logger.info("Skipping relation resolution - no file changes detected")
# Update scan watermark after successful sync # Update scan watermark after successful sync
# Use the timestamp from sync start (not end) to ensure we catch files # Use the timestamp from sync start (not end) to ensure we catch files
@@ -361,15 +351,6 @@ class SyncService:
duration_ms = int((time.time() - start_time) * 1000) duration_ms = int((time.time() - start_time) * 1000)
# Record metrics for sync operation
logfire.metric_histogram("sync.duration", unit="ms").record(duration_ms)
logfire.metric_counter("sync.files.new").add(len(report.new))
logfire.metric_counter("sync.files.modified").add(len(report.modified))
logfire.metric_counter("sync.files.deleted").add(len(report.deleted))
logfire.metric_counter("sync.files.moved").add(len(report.moves))
if report.skipped_files:
logfire.metric_counter("sync.files.skipped").add(len(report.skipped_files))
# Log summary with skipped files if any # Log summary with skipped files if any
if report.skipped_files: if report.skipped_files:
logger.warning( logger.warning(
@@ -390,7 +371,6 @@ class SyncService:
return report return report
@logfire.instrument()
async def scan(self, directory, force_full: bool = False): async def scan(self, directory, force_full: bool = False):
"""Smart scan using watermark and file count for large project optimization. """Smart scan using watermark and file count for large project optimization.
@@ -472,12 +452,6 @@ class SyncService:
logger.warning("No scan watermark available, falling back to full scan") logger.warning("No scan watermark available, falling back to full scan")
file_paths_to_scan = await self._scan_directory_full(directory) file_paths_to_scan = await self._scan_directory_full(directory)
# Record scan type metric
logfire.metric_counter(f"sync.scan.{scan_type}").add(1)
logfire.metric_histogram("sync.scan.files_scanned", unit="files").record(
len(file_paths_to_scan)
)
# Step 3: Process each file with mtime-based comparison # Step 3: Process each file with mtime-based comparison
scanned_paths: Set[str] = set() scanned_paths: Set[str] = set()
changed_checksums: Dict[str, str] = {} changed_checksums: Dict[str, str] = {}
@@ -589,7 +563,6 @@ class SyncService:
report.checksums = changed_checksums report.checksums = changed_checksums
scan_duration_ms = int((time.time() - scan_start_time) * 1000) scan_duration_ms = int((time.time() - scan_start_time) * 1000)
logfire.metric_histogram("sync.scan.duration", unit="ms").record(scan_duration_ms)
logger.info( logger.info(
f"Completed {scan_type} scan for directory {directory} in {scan_duration_ms}ms, " f"Completed {scan_type} scan for directory {directory} in {scan_duration_ms}ms, "
@@ -599,7 +572,6 @@ class SyncService:
) )
return report return report
@logfire.instrument()
async def sync_file( async def sync_file(
self, path: str, new: bool = True self, path: str, new: bool = True
) -> Tuple[Optional[Entity], Optional[str]]: ) -> Tuple[Optional[Entity], Optional[str]]:
@@ -638,6 +610,16 @@ class SyncService:
) )
return entity, checksum 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: except Exception as e:
# Check if this is a fatal error (or caused by one) # Check if this is a fatal error (or caused by one)
# Fatal errors like project deletion should terminate sync immediately # Fatal errors like project deletion should terminate sync immediately
@@ -654,7 +636,6 @@ class SyncService:
return None, None return None, None
@logfire.instrument()
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]: async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
"""Sync a markdown file with full processing. """Sync a markdown file with full processing.
@@ -672,12 +653,19 @@ class SyncService:
file_contains_frontmatter = has_frontmatter(file_content) file_contains_frontmatter = has_frontmatter(file_content)
# Get file timestamps for tracking modification times # Get file timestamps for tracking modification times
file_stats = self.file_service.file_stats(path) file_metadata = await self.file_service.get_file_metadata(path)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone() created = file_metadata.created_at
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone() modified = file_metadata.modified_at
# entity markdown will always contain front matter, so it can be used up create/update the entity # Parse markdown content with file metadata (avoids redundant file read/stat)
entity_markdown = await self.entity_parser.parse_file(path) # This enables cloud implementations (S3FileService) to provide metadata from head_object
abs_path = self.file_service.base_path / path
entity_markdown = await self.entity_parser.parse_markdown_content(
file_path=abs_path,
content=file_content,
mtime=file_metadata.modified_at.timestamp(),
ctime=file_metadata.created_at.timestamp(),
)
# if the file contains frontmatter, resolve a permalink (unless disabled) # if the file contains frontmatter, resolve a permalink (unless disabled)
if file_contains_frontmatter and not self.app_config.disable_permalinks: if file_contains_frontmatter and not self.app_config.disable_permalinks:
@@ -723,8 +711,8 @@ class SyncService:
"checksum": final_checksum, "checksum": final_checksum,
"created_at": created, "created_at": created,
"updated_at": modified, "updated_at": modified,
"mtime": file_stats.st_mtime, "mtime": file_metadata.modified_at.timestamp(),
"size": file_stats.st_size, "size": file_metadata.size,
}, },
) )
@@ -737,7 +725,6 @@ class SyncService:
# Return the final checksum to ensure everything is consistent # Return the final checksum to ensure everything is consistent
return entity, final_checksum return entity, final_checksum
@logfire.instrument()
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]: async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
"""Sync a non-markdown file with basic tracking. """Sync a non-markdown file with basic tracking.
@@ -754,9 +741,9 @@ class SyncService:
await self.entity_service.resolve_permalink(path, skip_conflict_check=True) await self.entity_service.resolve_permalink(path, skip_conflict_check=True)
# get file timestamps # get file timestamps
file_stats = self.file_service.file_stats(path) file_metadata = await self.file_service.get_file_metadata(path)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone() created = file_metadata.created_at
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone() modified = file_metadata.modified_at
# get mime type # get mime type
content_type = self.file_service.content_type(path) content_type = self.file_service.content_type(path)
@@ -772,8 +759,8 @@ class SyncService:
created_at=created, created_at=created,
updated_at=modified, updated_at=modified,
content_type=content_type, content_type=content_type,
mtime=file_stats.st_mtime, mtime=file_metadata.modified_at.timestamp(),
size=file_stats.st_size, size=file_metadata.size,
) )
) )
return entity, checksum return entity, checksum
@@ -789,15 +776,15 @@ class SyncService:
logger.error(f"Entity not found after constraint violation, path={path}") logger.error(f"Entity not found after constraint violation, path={path}")
raise ValueError(f"Entity not found after constraint violation: {path}") raise ValueError(f"Entity not found after constraint violation: {path}")
# Re-get file stats since we're in update path # Re-get file metadata since we're in update path
file_stats_for_update = self.file_service.file_stats(path) file_metadata_for_update = await self.file_service.get_file_metadata(path)
updated = await self.entity_repository.update( updated = await self.entity_repository.update(
entity.id, entity.id,
{ {
"file_path": path, "file_path": path,
"checksum": checksum, "checksum": checksum,
"mtime": file_stats_for_update.st_mtime, "mtime": file_metadata_for_update.modified_at.timestamp(),
"size": file_stats_for_update.st_size, "size": file_metadata_for_update.size,
}, },
) )
@@ -811,8 +798,8 @@ class SyncService:
raise raise
else: else:
# Get file timestamps for updating modification time # Get file timestamps for updating modification time
file_stats = self.file_service.file_stats(path) file_metadata = await self.file_service.get_file_metadata(path)
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone() modified = file_metadata.modified_at
entity = await self.entity_repository.get_by_file_path(path) entity = await self.entity_repository.get_by_file_path(path)
if entity is None: # pragma: no cover if entity is None: # pragma: no cover
@@ -827,8 +814,8 @@ class SyncService:
"file_path": path, "file_path": path,
"checksum": checksum, "checksum": checksum,
"updated_at": modified, "updated_at": modified,
"mtime": file_stats.st_mtime, "mtime": file_metadata.modified_at.timestamp(),
"size": file_stats.st_size, "size": file_metadata.size,
}, },
) )
@@ -838,7 +825,6 @@ class SyncService:
return updated, checksum return updated, checksum
@logfire.instrument()
async def handle_delete(self, file_path: str): async def handle_delete(self, file_path: str):
"""Handle complete entity deletion including search index cleanup.""" """Handle complete entity deletion including search index cleanup."""
@@ -870,7 +856,6 @@ class SyncService:
else: else:
await self.search_service.delete_by_entity_id(entity.id) await self.search_service.delete_by_entity_id(entity.id)
@logfire.instrument()
async def handle_move(self, old_path, new_path): async def handle_move(self, old_path, new_path):
logger.debug("Moving entity", old_path=old_path, new_path=new_path) logger.debug("Moving entity", old_path=old_path, new_path=new_path)
@@ -975,7 +960,6 @@ class SyncService:
# update search index # update search index
await self.search_service.index_entity(updated) await self.search_service.index_entity(updated)
@logfire.instrument()
async def resolve_relations(self, entity_id: int | None = None): async def resolve_relations(self, entity_id: int | None = None):
"""Try to resolve unresolved relations. """Try to resolve unresolved relations.
@@ -1026,16 +1010,27 @@ class SyncService:
"to_name": resolved_entity.title, "to_name": resolved_entity.title,
}, },
) )
except IntegrityError: # pragma: no cover # update search index only on successful resolution
await self.search_service.index_entity(resolved_entity)
except IntegrityError:
# IntegrityError means a relation with this (from_id, to_id, relation_type)
# already exists. The UPDATE was rolled back, so our unresolved relation
# (to_id=NULL) still exists in the database. We delete it because:
# 1. It's redundant - a resolved relation already captures this relationship
# 2. If we don't delete it, future syncs will try to resolve it again
# and get the same IntegrityError
logger.debug( logger.debug(
"Ignoring duplicate relation " "Deleting duplicate unresolved relation "
f"relation_id={relation.id} " f"relation_id={relation.id} "
f"from_id={relation.from_id} " f"from_id={relation.from_id} "
f"to_name={relation.to_name}" f"to_name={relation.to_name} "
f"resolved_to_id={resolved_entity.id}"
) )
try:
# update search index await self.relation_repository.delete(relation.id)
await self.search_service.index_entity(resolved_entity) except Exception as e:
# Log but don't fail - the relation may have been deleted already
logger.debug(f"Could not delete duplicate relation {relation.id}: {e}")
async def _quick_count_files(self, directory: Path) -> int: async def _quick_count_files(self, directory: Path) -> int:
"""Fast file count using find command. """Fast file count using find command.
@@ -1043,12 +1038,22 @@ class SyncService:
Uses subprocess to leverage OS-level file counting which is much faster Uses subprocess to leverage OS-level file counting which is much faster
than Python iteration, especially on network filesystems like TigrisFS. 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: Args:
directory: Directory to count files in directory: Directory to count files in
Returns: Returns:
Number of files in directory (recursive) 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( process = await asyncio.create_subprocess_shell(
f'find "{directory}" -type f | wc -l', f'find "{directory}" -type f | wc -l',
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@@ -1063,8 +1068,6 @@ class SyncService:
f"error: {error_msg}. Falling back to manual count. " f"error: {error_msg}. Falling back to manual count. "
f"This will slow down watermark detection!" f"This will slow down watermark detection!"
) )
# Track optimization failures for visibility
logfire.metric_counter("sync.scan.file_count_failure").add(1)
# Fallback: count using scan_directory # Fallback: count using scan_directory
count = 0 count = 0
async for _ in self.scan_directory(directory): async for _ in self.scan_directory(directory):
@@ -1081,6 +1084,9 @@ class SyncService:
This is dramatically faster than scanning all files and comparing mtimes, This is dramatically faster than scanning all files and comparing mtimes,
especially on network filesystems like TigrisFS where stat operations are expensive. 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: Args:
directory: Directory to scan directory: Directory to scan
since_timestamp: Unix timestamp to find files newer than since_timestamp: Unix timestamp to find files newer than
@@ -1088,6 +1094,16 @@ class SyncService:
Returns: Returns:
List of relative file paths modified since the timestamp (respects .bmignore) 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 # Convert timestamp to find-compatible format
since_date = datetime.fromtimestamp(since_timestamp).strftime("%Y-%m-%d %H:%M:%S") since_date = datetime.fromtimestamp(since_timestamp).strftime("%Y-%m-%d %H:%M:%S")
@@ -1105,8 +1121,6 @@ class SyncService:
f"error: {error_msg}. Falling back to full scan. " f"error: {error_msg}. Falling back to full scan. "
f"This will cause slow syncs on large projects!" f"This will cause slow syncs on large projects!"
) )
# Track optimization failures for visibility
logfire.metric_counter("sync.scan.optimization_failure").add(1)
# Fallback to full scan # Fallback to full scan
return await self._scan_directory_full(directory) return await self._scan_directory_full(directory)
@@ -1206,8 +1220,8 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
project_path = Path(project.path) project_path = Path(project.path)
entity_parser = EntityParser(project_path) entity_parser = EntityParser(project_path)
markdown_processor = MarkdownProcessor(entity_parser) markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
file_service = FileService(project_path, markdown_processor) file_service = FileService(project_path, markdown_processor, app_config=app_config)
# Initialize repositories # Initialize repositories
entity_repository = EntityRepository(session_maker, project_id=project.id) entity_repository = EntityRepository(session_maker, project_id=project.id)
+20 -5
View File
@@ -5,7 +5,10 @@ import os
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional, Set, Sequence from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHECKING
if TYPE_CHECKING:
from basic_memory.sync.sync_service import SyncService
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
@@ -71,12 +74,17 @@ class WatchServiceState(BaseModel):
self.last_error = datetime.now() self.last_error = datetime.now()
# Type alias for sync service factory function
SyncServiceFactory = Callable[[Project], Awaitable["SyncService"]]
class WatchService: class WatchService:
def __init__( def __init__(
self, self,
app_config: BasicMemoryConfig, app_config: BasicMemoryConfig,
project_repository: ProjectRepository, project_repository: ProjectRepository,
quiet: bool = False, quiet: bool = False,
sync_service_factory: Optional[SyncServiceFactory] = None,
): ):
self.app_config = app_config self.app_config = app_config
self.project_repository = project_repository self.project_repository = project_repository
@@ -84,10 +92,20 @@ class WatchService:
self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
self.status_path.parent.mkdir(parents=True, exist_ok=True) self.status_path.parent.mkdir(parents=True, exist_ok=True)
self._ignore_patterns_cache: dict[Path, Set[str]] = {} self._ignore_patterns_cache: dict[Path, Set[str]] = {}
self._sync_service_factory = sync_service_factory
# quiet mode for mcp so it doesn't mess up stdout # quiet mode for mcp so it doesn't mess up stdout
self.console = Console(quiet=quiet) self.console = Console(quiet=quiet)
async def _get_sync_service(self, project: Project) -> "SyncService":
"""Get sync service for a project, using factory if provided."""
if self._sync_service_factory:
return await self._sync_service_factory(project)
# Fall back to default factory
from basic_memory.sync.sync_service import get_sync_service
return await get_sync_service(project)
async def _schedule_restart(self, stop_event: asyncio.Event): async def _schedule_restart(self, stop_event: asyncio.Event):
"""Schedule a restart of the watch service after the configured interval.""" """Schedule a restart of the watch service after the configured interval."""
await asyncio.sleep(self.app_config.watch_project_reload_interval) await asyncio.sleep(self.app_config.watch_project_reload_interval)
@@ -233,9 +251,6 @@ class WatchService:
async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None: async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
"""Process a batch of file changes""" """Process a batch of file changes"""
# avoid circular imports
from basic_memory.sync.sync_service import get_sync_service
# Check if project still exists in configuration before processing # Check if project still exists in configuration before processing
# This prevents deleted projects from being recreated by background sync # This prevents deleted projects from being recreated by background sync
from basic_memory.config import ConfigManager from basic_memory.config import ConfigManager
@@ -250,7 +265,7 @@ class WatchService:
) )
return return
sync_service = await get_sync_service(project) sync_service = await self._get_sync_service(project)
file_service = sync_service.file_service file_service = sync_service.file_service
start_time = time.time() start_time = time.time()
+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]})
+67 -64
View File
@@ -5,9 +5,9 @@ import os
import logging import logging
import re import re
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Protocol, Union, runtime_checkable, List from typing import Protocol, Union, runtime_checkable, List
from loguru import logger from loguru import logger
from unidecode import unidecode from unidecode import unidecode
@@ -67,9 +67,6 @@ class PathLike(Protocol):
# This preserves compatibility with existing code while we migrate # This preserves compatibility with existing code while we migrate
FilePath = Union[Path, str] FilePath = Union[Path, str]
# Disable the "Queue is full" warning
logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str: def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str:
"""Generate a stable permalink from a file path. """Generate a stable permalink from a file path.
@@ -206,29 +203,37 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
def setup_logging( def setup_logging(
env: str,
home_dir: Path,
log_file: Optional[str] = None,
log_level: str = "INFO", log_level: str = "INFO",
console: bool = True, log_to_file: bool = False,
log_to_stdout: bool = False,
structured_context: bool = False,
) -> None: # pragma: no cover ) -> None: # pragma: no cover
""" """Configure logging with explicit settings.
Configure logging for the application.
This function provides a simple, explicit interface for configuring logging.
Each entry point (CLI, MCP, API) should call this with appropriate settings.
Args: Args:
env: The environment name (dev, test, prod) log_level: DEBUG, INFO, WARNING, ERROR
home_dir: The root directory for the application log_to_file: Write to ~/.basic-memory/basic-memory.log with rotation
log_file: The name of the log file to write to log_to_stdout: Write to stderr (for Docker/cloud deployments)
log_level: The logging level to use structured_context: Bind tenant_id, fly_region, etc. for cloud observability
console: Whether to log to the console
""" """
# Remove default handler and any existing handlers # Remove default handler and any existing handlers
logger.remove() logger.remove()
# Add file handler if we are not running tests and a log file is specified # In test mode, only log to stdout regardless of settings
if log_file and env != "test": env = os.getenv("BASIC_MEMORY_ENV", "dev")
# Setup file logger if env == "test":
log_path = home_dir / log_file logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
return
# Add file handler with rotation
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( logger.add(
str(log_path), str(log_path),
level=log_level, level=log_level,
@@ -236,42 +241,28 @@ def setup_logging(
retention="10 days", retention="10 days",
backtrace=True, backtrace=True,
diagnose=True, diagnose=True,
enqueue=True, enqueue=False,
colorize=False, colorize=False,
) )
# Add console logger if requested or in test mode # Add stdout handler (for Docker/cloud)
if env == "test" or console: if log_to_stdout:
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True) logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}") # Bind structured context for cloud observability
if structured_context:
# Bind environment context for structured logging (works in both local and cloud) logger.configure(
tenant_id = os.getenv("BASIC_MEMORY_TENANT_ID", "local") extra={
fly_app_name = os.getenv("FLY_APP_NAME", "local") "tenant_id": os.getenv("BASIC_MEMORY_TENANT_ID", "local"),
fly_machine_id = os.getenv("FLY_MACHINE_ID", "local") "fly_app_name": os.getenv("FLY_APP_NAME", "local"),
fly_region = os.getenv("FLY_REGION", "local") "fly_machine_id": os.getenv("FLY_MACHINE_ID", "local"),
"fly_region": os.getenv("FLY_REGION", "local"),
logger.configure( }
extra={ )
"tenant_id": tenant_id,
"fly_app_name": fly_app_name,
"fly_machine_id": fly_machine_id,
"fly_region": fly_region,
}
)
# Reduce noise from third-party libraries # Reduce noise from third-party libraries
noisy_loggers = { logging.getLogger("httpx").setLevel(logging.WARNING)
# HTTP client logs logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
"httpx": logging.WARNING,
# File watching logs
"watchfiles.main": logging.WARNING,
}
# Set log levels for noisy loggers
for logger_name, level in noisy_loggers.items():
logging.getLogger(logger_name).setLevel(level)
def parse_tags(tags: Union[List[str], str, None]) -> List[str]: def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
@@ -340,7 +331,7 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
This function normalizes file paths to help detect potential conflicts: This function normalizes file paths to help detect potential conflicts:
- Converts to lowercase for case-insensitive comparison - Converts to lowercase for case-insensitive comparison
- Normalizes Unicode characters - Normalizes Unicode characters
- Handles path separators consistently - Converts backslashes to forward slashes for cross-platform consistency
Args: Args:
file_path: The file path to normalize file_path: The file path to normalize
@@ -349,19 +340,15 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
Normalized file path for comparison purposes Normalized file path for comparison purposes
""" """
import unicodedata import unicodedata
from pathlib import PureWindowsPath
# Convert to lowercase for case-insensitive comparison # Use PureWindowsPath to ensure backslashes are treated as separators
normalized = file_path.lower() # regardless of current platform, then convert to POSIX-style
normalized = PureWindowsPath(file_path).as_posix().lower()
# Normalize Unicode characters (NFD normalization) # Normalize Unicode characters (NFD normalization)
normalized = unicodedata.normalize("NFD", normalized) normalized = unicodedata.normalize("NFD", normalized)
# Replace path separators with forward slashes
normalized = normalized.replace("\\", "/")
# Remove multiple slashes
normalized = re.sub(r"/+", "/", normalized)
return normalized return normalized
@@ -445,21 +432,37 @@ def validate_project_path(path: str, project_path: Path) -> bool:
return False return False
def ensure_timezone_aware(dt: datetime) -> datetime: def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datetime:
"""Ensure a datetime is timezone-aware using system timezone. """Ensure a datetime is timezone-aware.
If the datetime is naive, convert it to timezone-aware using the system's local timezone. If the datetime is naive, convert it to timezone-aware. The interpretation
If it's already timezone-aware, return it unchanged. depends on cloud_mode:
- In cloud mode (PostgreSQL/asyncpg): naive datetimes are interpreted as UTC
- In local mode (SQLite): naive datetimes are interpreted as local time
asyncpg uses binary protocol which returns timestamps in UTC but as naive
datetimes. In cloud deployments, cloud_mode=True handles this correctly.
Args: Args:
dt: The datetime to ensure is timezone-aware dt: The datetime to ensure is timezone-aware
cloud_mode: Optional explicit cloud_mode setting. If None, loads from config.
Returns: Returns:
A timezone-aware datetime A timezone-aware datetime
""" """
if dt.tzinfo is None: if dt.tzinfo is None:
# Naive datetime - assume it's in local time and add timezone # Determine cloud_mode: use explicit parameter if provided, otherwise load from config
return dt.astimezone() if cloud_mode is None:
from basic_memory.config import ConfigManager
cloud_mode = ConfigManager().config.cloud_mode_enabled
if cloud_mode:
# Cloud/PostgreSQL mode: naive datetimes from asyncpg are already UTC
return dt.replace(tzinfo=timezone.utc)
else:
# Local/SQLite mode: naive datetimes are in local time
return dt.astimezone()
else: else:
# Already timezone-aware # Already timezone-aware
return dt return dt

Some files were not shown because too many files have changed in this diff Show More