feat: v0.13.0 pre (#122)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-06-03 01:00:40 -05:00
committed by GitHub
parent 5ec4087f7c
commit 634486107b
116 changed files with 13122 additions and 2147 deletions
+53
View File
@@ -0,0 +1,53 @@
name: Dev Release
on:
push:
branches: [main]
workflow_dispatch: # Allow manual triggering
jobs:
dev-release:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: |
pip install uv
- name: Install dependencies and build
run: |
uv venv
uv sync
uv build
- name: Check if this is a dev version
id: check_version
run: |
VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [[ "$VERSION" == *"dev"* ]]; then
echo "is_dev=true" >> $GITHUB_OUTPUT
echo "Dev version detected: $VERSION"
else
echo "is_dev=false" >> $GITHUB_OUTPUT
echo "Release version detected: $VERSION, skipping dev release"
fi
- name: Publish dev version to PyPI
if: steps.check_version.outputs.is_dev == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true # Don't fail if version already exists
+29 -65
View File
@@ -1,96 +1,60 @@
name: Release
on:
workflow_dispatch:
inputs:
version_type:
description: 'Type of version bump (major, minor, patch)'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
push:
tags:
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
jobs:
release:
runs-on: ubuntu-latest
concurrency: release
permissions:
id-token: write
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Python Semantic Release
id: release
uses: python-semantic-release/python-semantic-release@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
if: steps.release.outputs.released == 'true'
with:
password: ${{ secrets.PYPI_TOKEN }}
- name: Publish to GitHub Release Assets
uses: python-semantic-release/publish-action@v9.8.9
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.release.outputs.tag }}
build-macos:
needs: release
if: needs.release.outputs.released == 'true'
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
- name: Set up Python "3.12"
uses: actions/setup-python@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: 'pip'
- name: Install librsvg
run: brew install librsvg
- name: Install uv
run: |
pip install uv
- name: Create virtual env
- name: Install dependencies and build
run: |
uv venv
- name: Install dependencies
run: |
uv sync
uv build
- name: Build macOS installer
- name: Verify version matches tag
run: |
make installer-mac
xattr -dr com.apple.quarantine "installer/build/Basic Memory Installer.app"
# Get version from built package
PACKAGE_VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
TAG_VERSION=${GITHUB_REF_NAME#v} # Remove 'v' prefix from tag
echo "Package version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "Version mismatch! Package: $PACKAGE_VERSION, Tag: $TAG_VERSION"
exit 1
fi
- name: Zip macOS installer
run: |
cd installer/build
zip -ry "Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip" "Basic Memory Installer.app"
- name: Upload macOS installer
uses: softprops/action-gh-release@v1
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: installer/build/Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip
tag_name: ${{ needs.release.outputs.tag }}
files: |
dist/*.whl
dist/*.tar.gz
generate_release_notes: true
tag_name: ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"basic-memory": {
"command": "uv",
"args": [
"--directory",
"/Users/phernandez/dev/basicmachines/basic-memory",
"run",
"src/basic_memory/cli/main.py",
"mcp"
]
}
}
}
+35 -1
View File
@@ -187,4 +187,38 @@ Basic Memory Pro is a desktop GUI application that wraps the basic-memory CLI/MC
- Multiple project support with visual switching interface
local repo: /Users/phernandez/dev/basicmachines/basic-memory-pro
github: https://github.com/basicmachines-co/basic-memory-pro
github: https://github.com/basicmachines-co/basic-memory-pro
## Release and Version Management
Basic Memory uses `uv-dynamic-versioning` for automatic version management based on git tags:
### Version Types
- **Development versions**: Automatically generated from commits (e.g., `0.12.4.dev26+468a22f`)
- **Beta releases**: Created by tagging with beta suffixes (e.g., `v0.13.0b1`, `v0.13.0rc1`)
- **Stable releases**: Created by tagging with version numbers (e.g., `v0.13.0`)
### Release Workflows
#### Development Builds (Automatic)
- Triggered on every push to `main` branch
- Publishes dev versions like `0.12.4.dev26+468a22f` to PyPI
- Allows continuous testing of latest changes
- Users install with: `pip install basic-memory --pre --force-reinstall`
#### Beta/RC Releases (Manual)
- Create beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
- Automatically builds and publishes to PyPI as pre-release
- Users install with: `pip install basic-memory --pre`
- Use for milestone testing before stable release
#### Stable Releases (Manual)
- Create version tag: `git tag v0.13.0 && git push origin v0.13.0`
- Automatically builds, creates GitHub release, and publishes to PyPI
- Users install with: `pip install basic-memory`
### For Development
- No manual version bumping required
- Versions automatically derived from git tags
- `pyproject.toml` uses `dynamic = ["version"]`
- `__init__.py` dynamically reads version from package metadata
+34
View File
@@ -144,6 +144,40 @@ agreement to the DCO.
- **Database Testing**: Use in-memory SQLite for testing database operations
- **Fixtures**: Use async pytest fixtures for setup and teardown
## Release Process
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
### Version Management
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
### Release Workflows
#### Development Builds
- Automatically published to PyPI on every commit to `main`
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
- Users install with: `pip install basic-memory --pre --force-reinstall`
#### Beta Releases
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
2. GitHub Actions automatically builds and publishes to PyPI
3. Users install with: `pip install basic-memory --pre`
#### Stable Releases
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
2. GitHub Actions automatically:
- Builds the package with version `0.13.0`
- Creates GitHub release with auto-generated notes
- Publishes to PyPI
3. Users install with: `pip install basic-memory`
### For Contributors
- No manual version bumping required
- Versions are automatically derived from git tags
- Focus on code changes, not version management
## Creating Issues
If you're planning to work on something, please create an issue first to discuss the approach. Include:
+42
View File
@@ -0,0 +1,42 @@
Looking at write_note, I can see it's a complete content replacement tool. An edit_note() tool would be really valuable for incremental changes. Here's my thinking:
Use Cases for edit_note():
- Append new sections to existing notes (most common)
- Update specific information without rewriting everything
- Add observations/relations to existing content
- Fix typos or update facts
- Prepend updates like meeting notes with timestamps
Proposed Design:
@mcp.tool()
async def edit_note(
identifier: str, # title, permalink, or memory:// URL
operation: str, # "append", "prepend", "replace_section", "find_replace"
content: str, # content to add/replace
section: str = None, # for replace_section - header name like "## Notes"
find_text: str = None, # for find_replace
) -> str:
Operations:
1. append - Add content to end (most useful)
edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y")
2. prepend - Add content to beginning
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\n- Progress on feature...")
3. replace_section - Replace content under specific header
edit_note("specs/api", "replace_section", "New API design...", section="## Implementation")
4. find_replace - Simple text replacement
edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0")
Implementation Flow:
1. Use read_note() internally to get current content
2. Apply the specified operation
3. Use existing PUT /knowledge/entities/{permalink} endpoint
4. Return similar summary as write_note()
This would be perfect for AI assistants making targeted updates without having to regenerate entire documents. The append operation alone would handle 80% of use cases.
Want me to implement this? I'd start with just append and prepend for v0.13.0 to keep it simple.
+186
View File
@@ -0,0 +1,186 @@
# Frontmatter Tag Search Implementation
## Overview
This document outlines the implementation of frontmatter tag search functionality for Basic Memory. The goal is to enable users to search for entities based on their frontmatter tags, improving discoverability of content.
## Current State
### What Works
- ✅ Tags are parsed from YAML frontmatter and stored in `entity.entity_metadata`
- ✅ FTS5 search infrastructure is in place
- ✅ Observation tags are already indexed and searchable
- ✅ Search metadata structure supports additional fields
### What's Missing
- ✅ Entity frontmatter tags are now included in search indexing (COMPLETED)
- ❌ No special tag search syntax (e.g., `tag:foo`) - Future Phase 2
### Example Data
Current entity metadata includes tags:
```json
{
"title": "Business Strategy Index",
"type": "note",
"permalink": "business/business-strategy-index",
"tags": ["business", "strategy", "planning", "organization"]
}
```
## Implementation Plan
### Phase 1: Basic Tag Search (v0.13.0) - LOW RISK ⭐
**Goal:** Make frontmatter tags searchable via regular text search
**Approach:** Add entity tags to `content_stems` during search indexing
**Benefits:**
- Users can search for tags as regular text
- Zero risk to existing search functionality
- Immediate value with minimal code changes
**Implementation Tasks:**
1. **Update Search Indexing** (`search_service.py`)
- Extract tags from `entity.entity_metadata`
- Add tags to `content_stems` for entity indexing
- Handle both string and list tag formats
2. **Add Tests**
- Test tag extraction from entity metadata
- Test searching for entities by tag content
- Test both list and string tag formats
3. **Verify Existing Tag Data**
- Ensure consistent tag format in metadata
- Test with real data from existing entities
### Phase 2: Enhanced Tag Search (Future) - MEDIUM RISK ⭐⭐⭐
**Goal:** Add dedicated tag search syntax (`tag:foo`)
**Approach:** Extend search query parsing and repository
**Benefits:**
- More precise tag-only searches
- Better search result categorization
- Foundation for advanced tag operations
**Implementation Tasks:**
- Update search query parsing to handle `tag:` prefix
- Add tag-specific search repository methods
- Update search result metadata to highlight tag matches
- Comprehensive testing of new search syntax
## File Changes Required (Phase 1)
### Primary Changes
1. **`src/basic_memory/services/search_service.py`**
- Update `index_entity_markdown()` method
- Add entity tag extraction logic
- Include tags in content_stems
2. **`tests/services/test_search_service.py`**
- Add test for entity tag indexing
- Add test for searching entities by tags
- Test tag format handling
### Supporting Changes
3. **`tests/mcp/test_tool_search.py`** (if exists)
- Add integration tests for tag search via MCP tools
## Success Criteria
### Phase 1 ✅ COMPLETED
- [x] Entity frontmatter tags are included in search index
- [x] Users can find entities by searching tag text
- [x] All existing search functionality continues to work
- [x] Test coverage for new functionality
- [x] Works with both list and string tag formats
### Phase 2 (Future)
- [ ] `tag:foo` syntax returns only entities with that tag
- [ ] Multiple tag search (`tag:foo tag:bar`)
- [ ] Tag autocomplete/suggestions
- [ ] Search result metadata shows matched tags
## Risk Assessment
### Phase 1 Risks: ⭐ VERY LOW
- **Code Impact:** ~20 lines in search service
- **Search Logic:** No changes to core search functionality
- **Backward Compatibility:** 100% - only adds to existing search content
- **Testing:** Straightforward unit tests required
### Phase 2 Risks: ⭐⭐⭐ MEDIUM
- **Code Impact:** Query parsing, repository methods, API changes
- **Search Logic:** New search syntax parsing required
- **Backward Compatibility:** Must maintain existing search behavior
- **Testing:** Complex query parsing and edge case testing
## Implementation Notes
### Tag Format Handling
Entity metadata contains tags in different formats:
```python
# List format (preferred)
"tags": ["business", "strategy", "planning"]
# String format (legacy)
"tags": "['documentation', 'tools', 'best-practices']"
# Empty
"tags": "[]"
```
The implementation must handle all formats gracefully.
### Search Content Inclusion
Tags will be added to `content_stems` which already includes:
- Entity title variants
- Entity content
- Permalink variants
- File path variants
Adding tags to this stream maintains consistency with existing search behavior.
## Implementation Details (Phase 1 COMPLETED)
### Changes Made
1. **`src/basic_memory/services/search_service.py`** ✅
- Added `_extract_entity_tags()` helper method to handle multiple tag formats
- Modified `index_entity_markdown()` to include entity tags in `content_stems`
- Added proper error handling for malformed tag data
2. **`tests/services/test_search_service.py`** ✅
- Added 8 comprehensive tests covering all tag formats and edge cases
- Tests verify tag extraction, search indexing, and search functionality
- Includes tests for both list and string tag formats
### Key Implementation Features
- **Robust Tag Parsing:** Handles list format, string format, and edge cases
- **Safe Evaluation:** Uses `ast.literal_eval()` for parsing string representations
- **Backward Compatible:** Zero impact on existing search functionality
- **Comprehensive Testing:** Full test coverage for all scenarios
### Tag Format Support
```python
# All these formats are now properly handled:
"tags": ["business", "strategy"] # List format
"tags": "['documentation', 'tools']" # String format
"tags": "[]" # Empty string
"tags": [] # Empty list
# Missing tags key or metadata - gracefully handled
```
## Next Steps (Future)
1. **Consider Phase 2:** Enhanced tag search syntax for future release
2. **Monitor Usage:** Track how users search for tags
3. **Gather Feedback:** Understand if `tag:foo` syntax would be valuable
4. **Performance Monitoring:** Ensure tag indexing doesn't impact performance
+89
View File
@@ -0,0 +1,89 @@
# v0.13.0 Release Issues
This document tracks the issues identified for the v0.13.0 release, organized by priority.
## High Priority Bug Fixes
These issues address core functionality problems and should be resolved first:
### ~~#118: [BUG] Non-standard tag markup in YAML frontmatter~~ ✅ COMPLETED
- **Impact**: Data quality issue affecting tag formatting
- **Description**: Tags are improperly formatted with `#` prefix and incorrect YAML indentation
- **Expected**: `tags:\n - basicmemory`
- **Actual**: `tags:\n- '#basicmemory'`
- **Complexity**: Low - straightforward formatting fix
- **User Impact**: High - affects all tag usage
- **Resolution**: Fixed in write_note.py by removing `#` prefix from tag formatting
### ~~#110: [BUG] `--project` flag ignored in some commands~~ ✅ COMPLETED
- **Impact**: Breaks multi-project functionality added in v0.12.3
- **Description**: Commands like `project info` and `sync` don't respect `--project` flag
- **Root Cause**: Inconsistent project parameter handling across CLI commands
- **Complexity**: Medium - requires CLI argument parsing review
- **User Impact**: High - breaks core multi-project workflow
- **Resolution**: Fixed CLI app callback to update global config when --project specified
### ~~#107: [BUG] Fails to update note ("already exists")~~ ✅ ALREADY RESOLVED
- **Impact**: Prevents updating existing notes via write_note tool
- **Description**: `write_note` errors when target file exists, breaking daily note workflows
- **Root Cause**: EntityParser couldn't handle absolute paths correctly
- **Complexity**: Medium - requires write_note behavior enhancement
- **User Impact**: High - breaks core knowledge management workflow
- **Resolution**: Fixed in commit 9bff1f7 - EntityParser now handles absolute paths correctly
## Medium Priority Enhancements
These features would improve user experience and can be added if time permits:
### ~~#52: Search frontmatter tags~~ ✅ COMPLETED
- **Impact**: Enhances search capabilities
- **Description**: Include YAML frontmatter tags in search index
- **Implementation**: Index tags in search metadata, possibly add "tag:" search prefix
- **Complexity**: Medium - requires search index modification
- **User Impact**: Medium - improves discoverability
- **Resolution**: Implemented Phase 1 - frontmatter tags now included in FTS5 search index
### ~~#93: Reliable write_note Behavior for Populating Link Placeholders~~ ✅ COMPLETED
- **Impact**: Improves WikiLink workflow
- **Description**: Handle system-generated placeholder files gracefully in write_note
- **Features Needed**:
- Detect and populate placeholder files
- Respect user-specified permalinks in frontmatter
- Consistent file conflict handling
- **Complexity**: High - requires significant write_note refactoring
- **User Impact**: Medium-High - smooths linking workflow
- **Resolution**: Fixed entity_service.py to parse frontmatter before permalink resolution. Both new and existing notes now respect custom permalinks specified in frontmatter.
## Lower Priority Issues
These issues are tracked but not planned for v0.13.0:
### External/Third-party
- **#116**: MseeP.ai badge PR (external contribution)
### Diagnostic/Investigation Needed
- **#99**: Timeout logs on Windows
- **#108**: Claude connection interruptions
- **#111**: Highlight app MCP errors
- **#97**: Notes become inaccessible on Windows 11
- **#96**: LLM not generating proper knowledge graph format
## Implementation Strategy
1. **Start with High Priority bugs** - these fix broken functionality
2. **Add Medium Priority enhancements** if time allows
3. **Investigate Lower Priority issues** for future releases
## Success Criteria for v0.13.0
- [x] YAML tag formatting follows standard specification
- [x] `--project` flag works consistently across all commands
- [x] `write_note` can update existing notes reliably
- [x] Custom permalinks in frontmatter are respected by write_note
- [x] Frontmatter tags are included in search index
- [x] Comprehensive test coverage for all fixes
- [ ] Documentation updates for any behavior changes
## Notes
This release focuses on stability and core functionality fixes rather than major new features. The goal is to ensure the multi-project system introduced in v0.12.3 works reliably and that basic knowledge management workflows are robust.
+168
View File
@@ -0,0 +1,168 @@
# move_note() Implementation Plan
## Overview
Implement `move_note()` MCP tool to move notes to new locations while maintaining database consistency and search indexing. Follows the established MCP → API → Service architecture pattern.
## Architecture
```
MCP Tool → API Route → Service Logic
move_note() → POST /knowledge/move → entity_service.move_entity()
```
## Implementation Tasks
### Phase 1: Service Layer
- [ ] Add `move_entity()` method to `EntityService`
- [ ] Handle file path resolution and validation
- [ ] Implement physical file move with rollback on failure
- [ ] Update database (file_path, permalink if configured, checksum)
- [ ] Update search index
- [ ] Add comprehensive error handling
### Phase 2: API Layer
- [ ] Create `MoveEntityRequest` schema in `schemas/`
- [ ] Add `POST /knowledge/move` route to `knowledge_router.py`
- [ ] Handle project parameter and validation
- [ ] Return formatted success/error messages
### Phase 3: MCP Tool
- [ ] Create `move_note.py` in `mcp/tools/`
- [ ] Implement tool with project parameter support
- [ ] Add to tool registry in `mcp/server.py`
- [ ] Follow existing tool patterns for httpx client usage
### Phase 4: Testing
- [ ] Unit tests for `EntityService.move_entity()`
- [ ] API route tests in `test_knowledge_router.py`
- [ ] MCP tool integration tests
- [ ] Error case testing (rollback scenarios)
- [ ] Cross-project move testing
## Detailed Implementation
### Service Method Signature
```python
# src/basic_memory/services/entity_service.py
async def move_entity(
self,
identifier: str, # title, permalink, or memory:// URL
destination_path: str, # new path relative to project root
project_config: ProjectConfig
) -> str:
"""Move entity to new location with database consistency."""
```
### API Schema
```python
# src/basic_memory/schemas/memory.py
class MoveEntityRequest(BaseModel):
identifier: str
destination_path: str
project: str
```
### MCP Tool Signature
```python
# src/basic_memory/mcp/tools/move_note.py
@tool
async def move_note(
identifier: str,
destination_path: str,
project: Optional[str] = None
) -> str:
"""Move a note to a new location, updating database and maintaining links."""
```
## Service Implementation Logic
### 1. Entity Resolution
- Use existing `link_resolver` to find entity by identifier
- Validate entity exists and get current file_path
- Get current project config for file operations
### 2. Path Validation
- Validate destination_path format
- Ensure destination directory can be created
- Check destination doesn't already exist
- Verify source file exists on filesystem
### 3. File Operations
- Create destination directory if needed
- Move physical file with `Path.rename()`
- Implement rollback on subsequent failures
### 4. Database Updates
- Update entity file_path
- Generate new permalink if `update_permalinks_on_move` is True
- Update frontmatter with new permalink if changed
- Recalculate and update checksum
- Use existing repository methods
### 5. Search Re-indexing
- Call `search_service.index_entity()` with updated entity
- Existing search cleanup should be handled automatically
## Error Handling
### Validation Errors
- Entity not found by identifier
- Source file doesn't exist on filesystem
- Destination already exists
- Invalid destination path format
### Operation Errors
- File system permission errors
- Database update failures
- Search index update failures
### Rollback Strategy
- On database failure: restore original file location
- On search failure: log error but don't rollback (search can be rebuilt)
- Clear error messages for each failure type
## Return Messages
### Success
```
✅ Note moved successfully
📁 **old/path.md** → **new/path.md**
🔗 Permalink updated: old-permalink → new-permalink
📊 Database and search index updated
<!-- Project: project-name -->
```
### Failure
```
❌ Move failed: [specific error message]
<!-- Project: project-name -->
```
## Testing Strategy
### Unit Tests
- `test_entity_service.py` - Add move_entity tests
- Path validation edge cases
- Permalink generation scenarios
- Error handling and rollback
### Integration Tests
- `test_knowledge_router.py` - API endpoint tests
- `test_tool_move_note.py` - MCP tool tests
- Cross-project move scenarios
- Full workflow from MCP to filesystem
### Edge Cases
- Moving to same location (no-op)
- Moving across project boundaries
- Moving files with complex wikilink references
- Concurrent move operations
## Future Enhancements (Not v0.13.0)
- Update wikilinks in other files that reference moved note
- Batch move operations
- Move with automatic link fixing
- Integration with git for move tracking
+6 -14
View File
@@ -1,23 +1,15 @@
.PHONY: install test test-module lint clean format type-check installer-mac installer-win check
.PHONY: install test test-module lint clean format type-check installer-mac installer-win check test-int
install:
pip install -e ".[dev]"
test:
test-unit:
uv run pytest -p pytest_mock -v
# Run tests for a specific module
# Usage: make test-module m=path/to/module.py [cov=module_path]
test-module:
@if [ -z "$(m)" ]; then \
echo "Usage: make test-module m=path/to/module.py [cov=module_path]"; \
exit 1; \
fi; \
if [ -z "$(cov)" ]; then \
uv run pytest $(m) -v; \
else \
uv run pytest $(m) -v --cov=$(cov); \
fi
test-int:
uv run pytest -p pytest_mock -v --no-cov test-int
test: test-unit test-int
lint:
ruff check . --fix
+311
View File
@@ -0,0 +1,311 @@
Current vs. Desired State
Current: Project context is fixed at startup → Restart required to switch
Desired: Fluid project switching during conversation → "Switch to my work-notes project"
## UX Scenarios to Consider
### Scenario 1: Project Discovery & Switching
User: "What projects do I have?"
Assistant: [calls list_projects()]
• personal-notes (active)
• work-project
• code-snippets
User: "Switch to work-project"
Assistant: [calls switch_project("work-project")]
✓ Switched to work-project
User: "What did I work on yesterday?"
Assistant: [calls recent_activity() in work-project context]
### Scenario 2: Cross-Project Operations
User: "Create a note about this meeting in my personal-notes project"
Assistant: [calls write_note(..., project="personal-notes")]
User: "Now search for 'API design' across all my projects"
Assistant: [calls search_across_projects("API design")]
### Scenario 3: Context Awareness
User: "Edit my todo list"
Assistant: [calls read_note("todo-list")]
📍 Note from work-project: "Todo List"
• Finish API documentation
• Review pull requests
## Design Options
### Option A: Session-Based Context
# New MCP tools for project management
switch_project("work-project") # Sets session context
list_projects() # Shows available projects
get_current_project() # Shows active project
# Existing tools use session context
edit_note("my-note", "append", "content") # Uses work-project
### Option B: Explicit Project Parameters
# Add optional project param to all tools
edit_note("my-note", "append", "content", project="personal-notes")
search_notes("query", project="work-project")
# If no project specified, use session default
edit_note("my-note", "append", "content") # Uses current context
### Option C: Hybrid (Most Flexible)
# Set default context
switch_project("work-project")
# Use context by default
edit_note("my-note", "append", "content")
# Override when needed
search_notes("query", project="personal-notes")
Technical Implementation Ideas
Session State Management
# Simple in-memory session store
SESSION_STORE = {
"session_123": {
"current_project": "work-project",
"default_project": "personal-notes"
}
}
## New MCP Tools
@tool
async def list_projects() -> str:
"""List all available projects."""
@tool
async def switch_project(project_name: str) -> str:
"""Switch to a different project context."""
@tool
async def get_current_project() -> str:
"""Show the currently active project."""
@tool
async def search_across_projects(query: str) -> str:
"""Search across all projects."""
@tool
async def set_default_project(project-name: str) -> str:
"""Set default project. Requires restart"""
## Enhanced Existing Tools
@tool
async def edit_note(
identifier: str,
operation: str,
content: str,
project: Optional[str] = None # New optional parameter
) -> str:
# If project not specified, use session context
project_id = project or get_session_project()
## UX Questions to Consider
1. Context Visibility: Should every tool response show which project it's operating on?
- we could add a footer or something to the tool result that the LLM could understand is just metadata, not to display to the user
2. Error Handling: What happens when you reference a non-existent project?
- we would need to validate the project as an input and show an error
3. Default Behavior: Should there be a "global search" that works across all projects?
- i'm thinking this is a "not now" thing
4. State Persistence: Should project context persist across MCP reconnections?
- I think we always startup with the "default" project. If the user wants to change it, they can update the config, or call the new tool.
5. Conversation Flow: How do we make project switching feel natural in conversation?
What's your vision for the ideal user experience? Should it feel more like:
- A file system: "cd into work-project, then edit my notes"
- A workspace switcher: "Switch to work mode" vs "Switch to personal mode"
- Context tags: "In work-project, show me recent activity"
Something like "lets switch to project X", LLM responds "ok we are working in project X, and shows project summary"
# Implementation Plan - Client-Side Project Management
## Overview
Implement ad-hoc project switching as an MCP-only feature. No API changes needed - just session state management on the MCP side with enhanced tools.
## Core Components
### 1. Session State Management
```python
# src/basic_memory/mcp/project_session.py
class ProjectSession:
"""Simple in-memory project context for MCP session."""
_current_project: Optional[str] = None
_default_project: Optional[str] = None
@classmethod
def initialize(cls, default_project: str):
"""Set the default project from config on startup."""
cls._default_project = default_project
cls._current_project = default_project
@classmethod
def get_current_project(cls) -> str:
return cls._current_project or cls._default_project or "main"
@classmethod
def set_current_project(cls, project_name: str):
cls._current_project = project_name
@classmethod
def get_default_project(cls) -> str:
return cls._default_project or "main"
```
### 2. New MCP Tools
File: `src/basic_memory/mcp/tools/project_management.py`
```python
@tool
async def list_projects() -> str:
"""List all available projects with their status."""
@tool
async def switch_project(project_name: str) -> str:
"""Switch to a different project context. Shows project summary after switching."""
@tool
async def get_current_project() -> str:
"""Show the currently active project and basic stats."""
@tool
async def set_default_project(project_name: str) -> str:
"""Set default project in config. Requires restart to take effect."""
```
### 3. Enhanced Existing Tools
Add optional `project` parameter to all existing tools:
- `edit_note(..., project: Optional[str] = None)`
- `write_note(..., project: Optional[str] = None)`
- `read_note(..., project: Optional[str] = None)`
- `search_notes(..., project: Optional[str] = None)`
- `recent_activity(..., project: Optional[str] = None)`
### 4. Tool Response Metadata
Add project context footer to all tool responses:
```python
def add_project_metadata(result: str, project_name: str) -> str:
"""Add project context as metadata footer."""
return f"{result}\n\n<!-- Project: {project_name} -->"
```
## Implementation Tasks
### Phase 1: Core Infrastructure ✅
- [x] Create `ProjectSession` class
- [x] Create `project_management.py` tools file
- [x] Initialize session state in MCP server startup
- [x] Add project validation utilities
### Phase 2: New Tools Implementation ✅
- [x] Implement `list_projects()`
- [x] Implement `switch_project()`
- [x] Implement `get_current_project()`
- [x] Implement `set_default_project()`
### Phase 3: Enhance Existing Tools ✅
- [x] Add `project` parameter to all existing tools
- [x] Update tools to use session context when project not specified
- [x] Add project metadata to tool responses
- [x] Update tool documentation
### Phase 4: Testing & Polish ✅
- [x] Add comprehensive tests for project management tools
- [x] Test cross-project operations
- [x] Test error handling for invalid projects
- [x] Update documentation and examples
- [x] All tests passing (146/146 MCP, 16/16 CLI)
- [x] 100% test coverage achieved
### Phase 5: v0.13.0 Additional Features
- [x] Implement `edit_note()` MCP tool (append/prepend operations)
- [ ] Add `move_note()` functionality
- [ ] Implement agent mode capabilities
- [ ] Update release notes
### Later
- [ ] Add prompt agent functionality
## Expected UX Flow
```
User: "What projects do I have?"
Assistant: [calls list_projects()]
Available projects:
• main (current, default)
• work-notes
• personal-journal
• code-snippets
---
User: "Switch to work-notes"
Assistant: [calls switch_project("work-notes")]
✓ Switched to work-notes project
Project Summary:
• 47 notes
• Last updated: 2 hours ago
• Recent activity: 3 notes modified today
---
User: "What did I work on yesterday?"
Assistant: [calls recent_activity() - uses work-notes context]
Recent activity in work-notes:
• Updated "API Design Notes"
• Created "Meeting with Team Lead"
• Modified "Project Timeline"
---
User: "Edit my todo list"
Assistant: [calls edit_note("todo-list", ...) - uses work-notes context]
Edited note (append) in work-notes:
• file_path: Todo List.md
• Added 2 lines to end of note
```
## Technical Details
### Error Handling
- Validate project names against available projects
- Show helpful error messages for non-existent projects
- Graceful fallback to default project on errors
### Context Visibility
- Add `<!-- Project: project-name -->` footer to all tool responses
- LLM can use this metadata but doesn't need to show to user
- Clear indication in tool responses which project is active
### State Management
- Session state resets to default project on MCP restart
- No persistence across reconnections (keeps it simple)
- Config changes require restart (matches current behavior)
+18
View File
@@ -376,6 +376,24 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
- [Managing multiple Projects](https://memory.basicmachines.co/docs/cli-reference#project)
- [Importing data from OpenAI/Claude Projects](https://memory.basicmachines.co/docs/cli-reference#import)
## Installation Options
### Stable Release
```bash
pip install basic-memory
```
### Beta/Pre-releases
```bash
pip install basic-memory --pre
```
### Development Builds
Development versions are automatically published on every commit to main with versions like `0.12.4.dev26+468a22f`:
```bash
pip install basic-memory --pre --force-reinstall
```
## License
AGPL-3.0
+202 -188
View File
@@ -2,222 +2,236 @@
## Overview
This is a major release that introduces multi-project support, OAuth authentication, server-side templating, and numerous improvements to the MCP server implementation. The codebase has been significantly refactored to support a unified database architecture while maintaining backward compatibility.
Basic Memory v0.13.0 is a **major release** that transforms Basic Memory into a true multi-project knowledge management system. This release introduces fluid project switching, advanced note editing capabilities, robust file management, and production-ready OAuth authentication - all while maintaining full backward compatibility.
**What's New for Users:**
- 🎯 **Switch between projects instantly** during conversations with Claude
- ✏️ **Edit notes incrementally** without rewriting entire documents
- 📁 **Move and organize notes** with full database consistency
- 🔍 **Search frontmatter tags** to discover content more easily
- 🔐 **OAuth authentication** for secure remote access
-**Development builds** automatically published for beta testing
**Key v0.13.0 Accomplishments:**
-**Complete Project Management System** - Project switching and project-specific operations
-**Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
-**File Management System** - Full move operations with database consistency and rollback protection
-**Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discoverability
-**Unified Database Architecture** - Single app-level database for better performance and project management
## Major Features
### 1. Multi-Project Support 🎯
- **Unified Database Architecture**: All projects now share a single SQLite database with proper isolation
- **Project Management API**: New endpoints for creating, updating, and managing projects
- **Project Configuration**: Projects can be defined in `config.json` and synced with the database
- **Default Project**: Backward compatibility maintained with automatic default project creation
- **Project Switching**: CLI commands and API endpoints now support project context
### 1. Multiple Project Management 🎯
### 2. OAuth 2.1 Authentication 🔐
- **Multiple Provider Support**:
- Basic (in-memory) provider for development
- Supabase provider for production deployments
- External providers (GitHub, Google) framework
- **JWT-based Access Tokens**: Secure token generation and validation
- **PKCE Support**: Enhanced security for authorization code flow
- **MCP Inspector Integration**: Full support for authenticated testing
- **CLI Commands**: `basic-memory auth register-client` and `basic-memory auth test-auth`
**Switch between projects instantly during conversations:**
### 3. Server-Side Template Engine 📝
- **Handlebars Templates**: Server-side rendering of prompts and responses
- **Custom Helpers**: Rich set of template helpers for formatting
- **Structured Output**: XML-formatted responses for better LLM consumption
- **Template Caching**: Improved performance with template compilation caching
```
💬 "What projects do I have?"
🤖 Available projects:
• main (current, default)
• work-notes
• personal-journal
• code-snippets
### 4. Enhanced Import System 📥
- **Unified Importer Framework**: Base class for all importers with consistent interface
- **API Support**: New `/import` endpoints for triggering imports via API
- **Progress Tracking**: Real-time progress updates during import operations
- **Multiple Formats**:
- ChatGPT conversations
- Claude conversations
- Claude projects
- Memory JSON format
💬 "Switch to work-notes"
🤖 ✓ Switched to work-notes project
Project Summary:
• 47 entities
• 125 observations
• 23 relations
### 5. Directory Navigation 📁
- **Directory Service**: Browse and navigate project file structure
- **API Endpoints**: `/directory/tree` and `/directory/list` endpoints
- **Hierarchical View**: Tree structure representation of knowledge base
## API Changes
### New Endpoints
#### Project Management
- `GET /projects` - List all projects
- `POST /projects` - Create new project
- `GET /projects/{project_id}` - Get project details
- `PUT /projects/{project_id}` - Update project
- `DELETE /projects/{project_id}` - Delete project
- `POST /projects/{project_id}/set-default` - Set default project
#### Import API
- `GET /{project}/import/types` - List available importers
- `POST /{project}/import/{importer_type}/analyze` - Analyze import source
- `POST /{project}/import/{importer_type}/preview` - Preview import
- `POST /{project}/import/{importer_type}/execute` - Execute import
#### Directory API
- `GET /{project}/directory/tree` - Get directory tree
- `GET /{project}/directory/list` - List directory contents
#### Prompt Templates
- `POST /{project}/prompts/search` - Search with formatted output
- `POST /{project}/prompts/continue-conversation` - Continue conversation with context
#### Management API
- `GET /management/sync/status` - Get sync status
- `POST /management/sync/start` - Start background sync
- `POST /management/sync/stop` - Stop background sync
### Updated Endpoints
All knowledge-related endpoints now require project context:
- `/{project}/entities`
- `/{project}/observations`
- `/{project}/search`
- `/{project}/memory`
## CLI Changes
### New Commands
- `basic-memory auth` - OAuth client management
- `basic-memory project create` - Create new project
- `basic-memory project list` - List all projects
- `basic-memory project set-default` - Set default project
- `basic-memory project delete` - Delete project
- `basic-memory project info` - Show project statistics
### Updated Commands
- Import commands now support `--project` flag
- Sync commands operate on all active projects by default
- MCP server defaults to stdio transport (use `--transport streamable-http` for HTTP)
## Configuration Changes
### config.json Structure
```json
{
"projects": {
"main": "~/basic-memory",
"my-project": "~/my-notes",
"work": "~/work/notes"
},
"default_project": "main",
"sync_changes": true
}
💬 "What did I work on yesterday?"
🤖 [Shows recent activity from work-notes project]
```
### Environment Variables
- `FASTMCP_AUTH_ENABLED` - Enable OAuth authentication
- `FASTMCP_AUTH_SECRET_KEY` - JWT signing key
- `FASTMCP_AUTH_PROVIDER` - OAuth provider type
- `FASTMCP_AUTH_REQUIRED_SCOPES` - Required OAuth scopes
**Key Capabilities:**
- **Instant Project Switching**: Change project context mid-conversation without restart
- **Project-Specific Operations**: Operations work within the currently active project context
- **Project Discovery**: List all available projects with status indicators
- **Session Context**: Maintains active project throughout conversation
- **Backward Compatibility**: Existing single-project setups continue to work seamlessly
## Database Changes
### 2. Advanced Note Editing ✏️
### New Tables
- `project` - Project definitions and metadata
- Migration: `5fe1ab1ccebe_add_projects_table.py`
**Edit notes incrementally without rewriting entire documents:**
### Schema Updates
- All knowledge tables now include `project_id` foreign key
- Search index updated to support project filtering
- Backward compatibility maintained via default project
```python
# Append new sections to existing notes
edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y")
## Performance Improvements
# Prepend timestamps to meeting notes
edit_note("meeting-notes", "prepend", "## 2025-05-27 Update\n- Progress update...")
- **Concurrent Initialization**: Projects initialize in parallel
- **Optimized Queries**: Better use of indexes and joins
- **Template Caching**: Compiled templates cached in memory
- **Batch Operations**: Reduced database round trips
# Replace specific sections under headers
edit_note("api-spec", "replace_section", "New implementation details", section="## Implementation")
## Bug Fixes
# Find and replace with validation
edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0", expected_replacements=2)
```
- Fixed duplicate initialization in MCP server startup
- Fixed JWT audience validation for OAuth tokens
- Fixed trailing slash requirement for MCP endpoints
- Corrected OAuth endpoint paths
- Fixed stdio transport initialization
- Improved error handling in file sync operations
- Fixed search result ranking and filtering
**Key Capabilities:**
- **Append Operations**: Add content to end of notes (most common use case)
- **Prepend Operations**: Add content to beginning of notes
- **Section Replacement**: Replace content under specific markdown headers
- **Find & Replace**: Simple text replacements with occurrence counting
- **Smart Error Handling**: Helpful guidance when operations fail
- **Project Context**: Works within the active project with session awareness
## Breaking Changes
### 3. Smart File Management 📁
- **Project Context Required**: API endpoints now require project context
- **Database Location**: Unified database at `~/.basic-memory/memory.db`
- **Import Module Restructure**: Import functionality moved to dedicated module
**Move and organize notes:**
## Migration Guide
```python
# Simple moves with automatic folder creation
move_note("my-note", "work/projects/my-note.md")
# Organize within the active project
move_note("shared-doc", "archive/old-docs/shared-doc.md")
# Rename operations
move_note("old-name", "same-folder/new-name.md")
```
**Key Capabilities:**
- **Database Consistency**: Updates file paths, permalinks, and checksums automatically
- **Search Reindexing**: Maintains search functionality after moves
- **Folder Creation**: Automatically creates destination directories
- **Project Isolation**: Operates within the currently active project
- **Link Preservation**: Maintains internal links and references
### 4. Enhanced Search & Discovery 🔍
**Find content more easily with improved search capabilities:**
- **Frontmatter Tag Search**: Tags from YAML frontmatter are now indexed and searchable
- **Improved Content Discovery**: Search across titles, content, tags, and metadata
- **Project-Scoped Search**: Search within the currently active project
- **Better Search Quality**: Enhanced FTS5 indexing with tag content inclusion
**Example:**
```yaml
---
title: Coffee Brewing Methods
tags: [coffee, brewing, equipment]
---
```
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
### 5. Unified Database Architecture 🗄️
**Single app-level database for better performance and project management:**
- **Migration from Per-Project DBs**: Moved from multiple SQLite files to single app database
- **Project Isolation**: Proper data separation with project_id foreign keys
- **Better Performance**: Optimized queries and reduced file I/O
## Complete MCP Tool Suite 🛠️
### New Project Management Tools
- **`list_projects()`** - Discover and list all available projects with status
- **`switch_project(project_name)`** - Change active project context during conversations
- **`get_current_project()`** - Show currently active project with statistics
- **`set_default_project(project_name)`** - Update default project configuration
### New Note Operations Tools
- **`edit_note()`** - Incremental note editing (append, prepend, find/replace, section replace)
- **`move_note()`** - Move notes with database consistency and search reindexing
### Enhanced Existing Tools
All existing tools now support:
- **Session context awareness** (operates within the currently active project)
- **Enhanced error messages** with project context metadata
- **Improved response formatting** with project information footers
- **Project isolation** ensures operations stay within the correct project boundaries
## User Experience Improvements
### Installation Options
**Multiple ways to install and test Basic Memory:**
```bash
# Stable release
uv tool install basic-memory
# Beta/pre-releases
uv tool install basic-memory --pre
```
### Bug Fixes & Quality Improvements
**Major issues resolved in v0.13.0:**
- **#118**: Fixed YAML tag formatting to follow standard specification
- **#110**: Fixed `--project` flag consistency across all CLI commands
- **#107**: Fixed write_note update failures with existing notes
- **#93**: Fixed custom permalink handling in frontmatter
- **#52**: Enhanced search capabilities with frontmatter tag indexing
- **FTS5 Search**: Fixed special character handling in search queries
- **Error Handling**: Improved error messages and validation across all tools
## Breaking Changes & Migration
### For Existing Users
1. **Automatic Migration**: First run will migrate existing data to default project
2. **Project Configuration**: Add projects to `config.json` if using multiple projects
3. **API Updates**: Update API calls to include project context
**Automatic Migration**: First run will automatically migrate existing data to the new unified database structure. No manual action required.
### For API Consumers
**What Changes:**
- Database location: Moved to `~/.basic-memory/memory.db` (unified across projects)
- Configuration: Projects defined in `~/.basic-memory/config.json` are synced with database
```python
# Old
response = client.get("/entities")
**What Stays the Same:**
- All existing notes and data remain unchanged
- Default project behavior maintained for single-project users
- All existing MCP tools continue to work without modification
# New
response = client.get("/main/entities") # 'main' is default project
## Documentation & Resources
### New Documentation
- [Project Management Guide](docs/Project%20Management.md) - Multi-project workflows
- [Note Editing Guide](docs/Note%20Editing.md) - Advanced editing techniques
### Updated Documentation
- [README.md](README.md) - Installation options and beta build instructions
- [CONTRIBUTING.md](CONTRIBUTING.md) - Release process and version management
- [CLAUDE.md](CLAUDE.md) - Development workflow and CI/CD documentation
- [Claude.ai Integration](docs/Claude.ai%20Integration.md) - Updated MCP tool examples
### Quick Start Examples
**Project Switching:**
```
💬 "Switch to my work project and show recent activity"
🤖 [Calls switch_project("work") then recent_activity()]
```
### For OAuth Setup
**Note Editing:**
```
💬 "Add a section about deployment to my API docs"
🤖 [Calls edit_note("api-docs", "append", "## Deployment\n...")]
```
**File Organization:**
```
💬 "Move my old meeting notes to the archive folder"
🤖 [Calls move_note("meeting-notes", "archive/old-meetings.md")]
```
### Getting Updates
```bash
# Enable OAuth
export FASTMCP_AUTH_ENABLED=true
export FASTMCP_AUTH_SECRET_KEY="your-secret-key"
# Stable releases
uv tool upgrade basic-memory
# Start server
basic-memory mcp --transport streamable-http
# Beta releases
uv tool install basic-memory --pre --force-reinstall
# Get token
basic-memory auth test-auth
```
## Dependencies
### Added
- `python-dotenv` - Environment variable management
- `pydantic` >= 2.0 - Enhanced validation
### Updated
- `fastmcp` to latest version
- `mcp` to latest version
- All development dependencies updated
## Documentation
- New: [OAuth Authentication Guide](docs/OAuth%20Authentication%20Guide.md)
- New: [Supabase OAuth Setup](docs/Supabase%20OAuth%20Setup.md)
- Updated: [Claude.ai Integration](docs/Claude.ai%20Integration.md)
- Updated: Main README with project examples
## Testing
- Added comprehensive test coverage for new features
- OAuth provider tests with full flow validation
- Template engine tests with various scenarios
- Project service integration tests
- Import system unit tests
## Contributors
This release includes contributions from the Basic Machines team and the AI assistant Claude, demonstrating effective human-AI collaboration in software development.
## Next Steps
- Production deployment guide updates
- Additional OAuth provider implementations
- Performance profiling and optimization
- Enhanced project analytics features
# Latest development
uv tool install basic-memory --pre --force-reinstall
```
+337
View File
@@ -0,0 +1,337 @@
# Manual Testing Suite for Basic Memory
This document outlines a comprehensive manual testing approach where an AI assistant (Claude) executes real-world usage scenarios using Basic Memory's MCP tools. The unique aspect: **Basic Memory tests itself** - all test observations and results are recorded as notes in a dedicated test project.
## Philosophy
- **Integration over Isolation**: Test the full MCP→API→DB→File stack
- **Real Usage Patterns**: Creative exploration, not just checklist validation
- **Self-Documenting**: Use Basic Memory to record all test observations
- **Living Documentation**: Test results become part of the knowledge base
## Setup Instructions
### 1. Environment Preparation
```bash
# Ensure latest basic-memory is installed
pip install --upgrade basic-memory
# Verify MCP server is available
basic-memory --version
```
### 2. MCP Integration Setup
**Option A: Claude Desktop Integration**
```json
// Add to ~/.config/claude-desktop/claude_desktop_config.json
// or
// .mcp.json
{
"mcpServers": {
"basic-memory": {
"command": "uv",
"args": [
"--directory",
"/Users/phernandez/dev/basicmachines/basic-memory",
"run",
"src/basic_memory/cli/main.py",
"mcp"
]
}
}
}
```
**Option B: Claude Code MCP**
```bash
claude mcp add basic-memory basic-memory mcp
```
### 3. Test Project Creation
During testing, create a dedicated test project:
```
- Project name: "basic-memory-testing"
- Location: ~/basic-memory-testing
- Purpose: Contains all test observations and results
```
## Testing Categories
### Phase 1: Core Functionality Validation
**Objective**: Verify all basic operations work correctly
**Test Areas:**
- [ ] **Note Creation**: Various content types, structures, frontmatter
- [ ] **Note Reading**: By title, path, memory:// URLs, non-existent notes
- [ ] **Search Operations**: Simple queries, boolean operators, tag searches
- [ ] **Context Building**: Different depths, timeframes, relation traversal
- [ ] **Recent Activity**: Various timeframes, filtering options
**Success Criteria:**
- All operations complete without errors
- Files appear correctly in filesystem
- Search returns expected results
- Context includes appropriate related content
**Observations to Record:**
```markdown
# Core Functionality Test Results
## Test Execution
- [timestamp] Test started at 2025-01-06 15:30:00
- [setup] Created test project successfully
- [environment] MCP connection established
## write_note Tests
- [success] Basic note creation works
- [success] Frontmatter tags are preserved
- [issue] Special characters in titles need investigation
## Relations
- validates [[Search Operations Test]]
- part_of [[Manual Testing Suite]]
```
### Phase 2: v0.13.0 Feature Deep Dive
**Objective**: Thoroughly test new project management and editing capabilities
**Project Management Tests:**
- [ ] Create multiple projects dynamically
- [ ] Switch between projects mid-conversation
- [ ] Cross-project operations (create notes in different projects)
- [ ] Project discovery and status checking
- [ ] Default project behavior
**Note Editing Tests:**
- [ ] Append operations (add content to end)
- [ ] Prepend operations (add content to beginning)
- [ ] Find/replace operations with validation
- [ ] Section replacement under headers
- [ ] Edit operations across different projects
**File Management Tests:**
- [ ] Move notes within same project
- [ ] Move notes between projects
- [ ] Automatic folder creation during moves
- [ ] Move operations with special characters
- [ ] Database consistency after moves
**Success Criteria:**
- Project switching preserves context correctly
- Edit operations modify files as expected
- Move operations maintain database consistency
- Search indexes update after moves and edits
### Phase 3: Edge Case Exploration
**Objective**: Discover limits and handle unusual scenarios gracefully
**Boundary Testing:**
- [ ] Very long note titles and content
- [ ] Empty notes and projects
- [ ] Special characters: unicode, emojis, symbols
- [ ] Deeply nested folder structures
- [ ] Circular relations and self-references
**Error Scenario Testing:**
- [ ] Invalid memory:// URLs
- [ ] Missing files referenced in database
- [ ] Concurrent operations (if possible)
- [ ] Invalid project names
- [ ] Disk space constraints (if applicable)
**Performance Testing:**
- [ ] Large numbers of notes (100+)
- [ ] Complex search queries
- [ ] Deep relation chains (5+ levels)
- [ ] Rapid successive operations
### Phase 4: Real-World Workflow Scenarios
**Objective**: Test realistic usage patterns that users might follow
**Scenario 1: Meeting Notes Pipeline**
1. Create meeting notes with action items
2. Extract action items into separate notes
3. Link to project planning documents
4. Update progress over time using edit operations
5. Archive completed items
**Scenario 2: Research Knowledge Building**
1. Create research topic notes
2. Build complex relation networks
3. Add incremental findings over time
4. Search and discover connections
5. Reorganize as knowledge grows
**Scenario 3: Multi-Project Workflow**
1. Work project: Technical documentation
2. Personal project: Recipe collection
3. Learning project: Course notes
4. Switch between projects during conversation
5. Cross-reference related concepts
**Scenario 4: Content Evolution**
1. Start with basic notes
2. Gradually enhance with relations
3. Reorganize file structure
4. Update existing content incrementally
5. Build comprehensive knowledge graph
### Phase 5: Creative Stress Testing
**Objective**: Push the system to discover unexpected behaviors
**Creative Exploration Areas:**
- [ ] Rapid project creation and switching
- [ ] Unusual but valid markdown structures
- [ ] Creative use of observation categories
- [ ] Novel relation types and patterns
- [ ] Combining tools in unexpected ways
**Stress Scenarios:**
- [ ] Bulk operations (create many notes quickly)
- [ ] Complex nested moves and edits
- [ ] Deep context building with large graphs
- [ ] Search with complex boolean expressions
## Test Execution Process
### Pre-Test Checklist
- [ ] MCP connection verified
- [ ] Test project created
- [ ] Baseline notes recorded
### During Testing
1. **Execute test scenarios** using actual MCP tool calls
2. **Record observations** immediately in test project
3. **Note timestamps** for performance tracking
4. **Document any errors** with reproduction steps
5. **Explore variations** when something interesting happens
### Test Observation Format
Record all observations as Basic Memory notes using this structure:
```markdown
---
title: Test Session YYYY-MM-DD HH:MM
tags: [testing, session, v0.13.0]
---
# Test Session YYYY-MM-DD HH:MM
## Test Focus
- Primary objective
- Features being tested
## Observations
- [success] Feature X worked as expected #functionality
- [performance] Operation Y took 2.3 seconds #timing
- [issue] Error with special characters #bug
- [enhancement] Could improve UX for scenario Z #improvement
## Discovered Issues
- [bug] Description of problem with reproduction steps
- [limitation] Current system boundary encountered
## Relations
- tests [[Feature X]]
- part_of [[Manual Testing Suite]]
- found_issue [[Bug Report: Special Characters]]
```
### Post-Test Analysis
- [ ] Review all test observations
- [ ] Create summary report with findings
- [ ] Identify patterns in successes/failures
- [ ] Generate improvement recommendations
## Success Metrics
**Quantitative Measures:**
- % of test scenarios completed successfully
- Number of bugs discovered and documented
- Performance benchmarks established
- Coverage of all MCP tools and operations
**Qualitative Measures:**
- Natural conversation flow maintained
- Knowledge graph quality and connections
- User experience insights captured
- System reliability under various conditions
## Expected Outcomes
**For the System:**
- Validation of v0.13.0 features in real usage
- Discovery of edge cases not covered by unit tests
- Performance baseline establishment
- Bug identification with reproduction cases
**For the Knowledge Base:**
- Comprehensive testing documentation
- Real usage examples for documentation
- Edge case scenarios for future reference
- Performance insights and optimization opportunities
**For Development:**
- Priority list for bug fixes
- Enhancement ideas from real usage
- Validation of architectural decisions
- User experience insights
## Test Reporting
All test results will be captured in the Basic Memory test project, creating a living knowledge base of:
- Test execution logs with detailed observations
- Bug reports with reproduction steps
- Performance benchmarks and timing data
- Feature enhancement ideas discovered during testing
- Knowledge graphs showing test coverage relationships
- Summary reports for development team review
This approach ensures that the testing process itself validates Basic Memory's core value proposition: effectively capturing, organizing, and connecting knowledge through natural interaction patterns.
## Things to note
### User Experience & Usability:
- are tool instructions clear with working examples?
- Do error messages provide actionable guidance for resolution?
- Are response times acceptable for interactive use?
- Do tools feel consistent in their parameter patterns and behavior?
- Can users easily discover what tools are available and their capabilities?
### System Behavior:
- Does context preservation work as expected across tool calls?
- Do memory:// URLs behave intuitively for knowledge navigation?
- How well do tools work together in multi-step workflows?
- Does the system gracefully handle edge cases and invalid inputs?
### Documentation Alignment:
- does tool output provide clear results and helpful information?
- Do actual tool behaviors match their documented descriptions?
- Are the examples in tool help accurate and useful?
- Do real-world usage patterns align with documented workflows?
### Mental Model Validation:
- Does the system work the way users would naturally expect?
- Are there surprising behaviors that break user assumptions?
- Can users easily recover from mistakes or wrong turns?
- Do the knowledge graph concepts (entities, relations, observations) feel natural?
### Performance & Reliability:
- Do operations complete in reasonable time for the data size?
- Is system behavior consistent across multiple test sessions?
- How does performance change as the knowledge base grows?
- Are there any operations that feel unexpectedly slow?
---
**Ready to begin testing?** Start by creating the test project and recording your first observation about the testing setup process itself.
+155 -145
View File
@@ -10,6 +10,26 @@ You can [download](https://github.com/basicmachines-co/basic-memory/blob/main/do
This guide helps you, the AI assistant, use Basic Memory tools effectively when working with users. It covers reading, writing, and navigating knowledge through the Model Context Protocol (MCP).
## Quick Reference
**Essential Tools:**
- `write_note()` - Create/update notes (primary tool)
- `read_note()` - Read existing content
- `search_notes()` - Find information
- `edit_note()` - Modify existing notes incrementally (v0.13.0)
- `move_note()` - Organize files with database consistency (v0.13.0)
**Project Management (v0.13.0):**
- `list_projects()` - Show available projects
- `switch_project()` - Change active project
- `get_current_project()` - Current project info
**Key Principles:**
1. **Build connections** - Rich knowledge graphs > isolated notes
2. **Ask permission** - "Would you like me to record this?"
3. **Use exact titles** - For accurate `[[WikiLinks]]`
4. **Leverage v0.13.0** - Edit incrementally, organize proactively, switch projects contextually
## Overview
Basic Memory allows you and users to record context in local Markdown files, building a rich knowledge base through natural conversations. The system automatically creates a semantic knowledge graph from simple text patterns.
@@ -37,51 +57,59 @@ Remember that a knowledge graph with 10 heavily connected notes is more valuable
## Core Tools Reference
```python
# Writing knowledge - THE MOST IMPORTANT TOOL!
response = await write_note(
title="Search Design", # Required: Note title
content="# Search Design\n...", # Required: Note content
folder="specs", # Optional: Folder to save in
tags=["search", "design"], # Optional: Tags for categorization
verbose=True # Optional: Get parsing details
)
### Essential Content Management
# Reading knowledge
content = await read_note("Search Design") # By title
content = await read_note("specs/search-design") # By path
content = await read_note("memory://specs/search") # By memory URL
# Searching for knowledge
results = await search_notes(
query="authentication system", # Text to search for
page=1, # Optional: Pagination
page_size=10 # Optional: Results per page
**Writing knowledge** (most important tool):
```
write_note(
title="Search Design",
content="# Search Design\n...",
folder="specs", # Optional
tags=["search", "design"], # v0.13.0: now searchable!
project="work-notes" # v0.13.0: target specific project
)
```
# Building context from the knowledge graph
context = await build_context(
url="memory://specs/search", # Starting point
depth=2, # Optional: How many hops to follow
timeframe="1 month" # Optional: Recent timeframe
)
**Reading knowledge:**
```
read_note("Search Design") # By title
read_note("specs/search-design") # By path
read_note("memory://specs/search") # By memory URL
```
# Checking recent changes
activity = await recent_activity(
type="all", # Optional: Entity types to include
depth=1, # Optional: Related items to include
timeframe="1 week" # Optional: Time window
**Incremental editing** (v0.13.0):
```
edit_note(
identifier="Search Design",
operation="append", # append, prepend, find_replace, replace_section
content="\n## New Section\nContent here..."
)
```
# Creating a knowledge visualization
canvas_result = await canvas(
nodes=[{"id": "note1", "label": "Search Design"}], # Nodes to display
edges=[{"from": "note1", "to": "note2"}], # Connections
title="Project Overview", # Canvas title
folder="diagrams" # Storage location
**File organization** (v0.13.0):
```
move_note(
identifier="Old Note",
destination="archive/old-note.md" # Folders created automatically
)
```
### Project Management (v0.13.0)
```
list_projects() # Show available projects
switch_project("work-notes") # Change active project
get_current_project() # Current project info
```
### Search & Discovery
```
search_notes("authentication system") # v0.13.0: includes frontmatter tags
build_context("memory://specs/search") # Follow knowledge graph connections
recent_activity(timeframe="1 week") # Check what's been updated
```
## memory:// URLs Explained
Basic Memory uses a special URL format to reference entities in the knowledge graph:
@@ -158,6 +186,30 @@ Users will interact with Basic Memory in patterns like:
[Then build_context() to understand connections]
```
4. **Editing existing notes (v0.13.0)**:
```
Human: "Add a section about deployment to my API documentation"
You: I'll add that section to your existing documentation.
[Use edit_note() with operation="append" to add new content]
```
5. **Project management (v0.13.0)**:
```
Human: "Switch to my work project and show recent activity"
You: I'll switch to your work project and check what's been updated recently.
[Use switch_project() then recent_activity()]
```
6. **File organization (v0.13.0)**:
```
Human: "Move my old meeting notes to the archive folder"
You: I'll organize those notes for you.
[Use move_note() to relocate files with database consistency]
```
## Key Things to Remember
1. **Files are Truth**
@@ -174,16 +226,27 @@ Users will interact with Basic Memory in patterns like:
- Combine related information
3. **Writing Knowledge Wisely**
- Using the same title+folder will overwrite existing notes
- Structure content with clear headings and sections
- Use semantic markup for observations and relations
- Same title+folder overwrites existing notes
- Structure with clear headings and semantic markup
- Use tags for searchability (v0.13.0: frontmatter tags indexed)
- Keep files organized in logical folders
4. **Leverage v0.13.0 Features**
- **Edit incrementally**: Use `edit_note()` for small changes vs rewriting
- **Switch projects**: Change context when user mentions different work areas
- **Organize proactively**: Move old content to archive folders
- **Cross-project operations**: Create notes in specific projects while maintaining context
## Common Knowledge Patterns
### Capturing Decisions
```markdown
---
title: Coffee Brewing Methods
tags: [coffee, brewing, pour-over, techniques] # v0.13.0: Now searchable!
---
# Coffee Brewing Methods
## Context
@@ -196,11 +259,13 @@ Pour over is my preferred method for light to medium roasts because it highlight
- [technique] Blooming the coffee grounds for 30 seconds improves extraction #brewing
- [preference] Water temperature between 195-205°F works best #temperature
- [equipment] Gooseneck kettle provides better control of water flow #tools
- [timing] Total brew time of 3-4 minutes produces optimal extraction #process
## Relations
- pairs_with [[Light Roast Beans]]
- contrasts_with [[French Press Method]]
- requires [[Proper Grinding Technique]]
- part_of [[Morning Coffee Routine]]
```
### Recording Project Structure
@@ -241,119 +306,63 @@ Discussed strategies for improving the chocolate chip cookie recipe.
- pairs_with [[Homemade Ice Cream]]
```
## v0.13.0 Workflow Examples
### Multi-Project Conversations
**User:** "I need to update my work documentation and also add a personal recipe note."
**Workflow:**
1. `list_projects()` - Check available projects
2. `write_note(title="Sprint Planning", project="work-notes")` - Work content
3. `write_note(title="Weekend Recipes", project="personal")` - Personal content
### Incremental Note Building
**User:** "Add a troubleshooting section to my setup guide."
**Workflow:**
1. `edit_note(identifier="Setup Guide", operation="append", content="\n## Troubleshooting\n...")`
**User:** "Update the authentication section in my API docs."
**Workflow:**
1. `edit_note(identifier="API Documentation", operation="replace_section", section="## Authentication")`
### Smart File Organization
**User:** "My notes are getting messy in the main folder."
**Workflow:**
1. `move_note("Old Meeting Notes", "archive/2024/old-meetings.md")`
2. `move_note("Project Notes", "projects/client-work/notes.md")`
### Creating Effective Relations
When creating relations, you can:
1. Reference existing entities by their exact title
2. Create forward references to entities that don't exist yet
When creating relations:
1. **Reference existing entities** by their exact title: `[[Exact Title]]`
2. **Create forward references** to entities that don't exist yet - they'll be linked automatically when created
3. **Search first** to find existing entities to reference
4. **Use meaningful relation types**: `implements`, `requires`, `part_of` vs generic `relates_to`
```python
# Example workflow for creating notes with effective relations
async def create_note_with_effective_relations():
# Search for existing entities to reference
search_results = await search_notes("travel")
existing_entities = [result.title for result in search_results.primary_results]
# Check if specific entities exist
packing_tips_exists = "Packing Tips" in existing_entities
japan_travel_exists = "Japan Travel Guide" in existing_entities
# Prepare relations section - include both existing and forward references
relations_section = "## Relations\n"
# Existing reference - exact match to known entity
if packing_tips_exists:
relations_section += "- references [[Packing Tips]]\n"
else:
# Forward reference - will be linked when that entity is created later
relations_section += "- references [[Packing Tips]]\n"
# Another possible reference
if japan_travel_exists:
relations_section += "- part_of [[Japan Travel Guide]]\n"
# You can also check recently modified notes to reference them
recent = await recent_activity(timeframe="1 week")
recent_titles = [item.title for item in recent.primary_results]
if "Transportation Options" in recent_titles:
relations_section += "- relates_to [[Transportation Options]]\n"
# Always include meaningful forward references, even if they don't exist yet
relations_section += "- located_in [[Tokyo]]\n"
relations_section += "- visited_during [[Spring 2023 Trip]]\n"
# Now create the note with both verified and forward relations
content = f"""# Tokyo Neighborhood Guide
## Overview
Details about different Tokyo neighborhoods and their unique characteristics.
**Example workflow:**
1. `search_notes("travel")` to find existing travel-related notes
2. Reference found entities: `- part_of [[Japan Travel Guide]]`
3. Add forward references: `- located_in [[Tokyo]]` (even if Tokyo note doesn't exist yet)
## Observations
- [area] Shibuya is a busy shopping district #shopping
- [transportation] Yamanote Line connects major neighborhoods #transit
- [recommendation] Visit Shimokitazawa for vintage shopping #unique
- [tip] Get a Suica card for easy train travel #convenience
## Common Issues & Solutions
{relations_section}
"""
result = await write_note(
title="Tokyo Neighborhood Guide",
content=content,
verbose=True
)
# You can check which relations were resolved and which are forward references
if result and 'relations' in result:
resolved = [r['to_name'] for r in result['relations'] if r.get('target_id')]
forward_refs = [r['to_name'] for r in result['relations'] if not r.get('target_id')]
print(f"Resolved relations: {resolved}")
print(f"Forward references that will be resolved later: {forward_refs}")
```
**Missing Content:**
- Try `search_notes()` with broader terms if `read_note()` fails
- Use fuzzy matching: search for partial titles
## Error Handling
**Forward References:**
- These are normal! Basic Memory links them automatically when target notes are created
- Inform users: "I've created forward references that will be linked when you create those notes"
Common issues to watch for:
1. **Missing Content**
```python
try:
content = await read_note("Document")
except:
# Try search instead
results = await search_notes("Document")
if results and results.primary_results:
# Found something similar
content = await read_note(results.primary_results[0].permalink)
```
2. **Forward References (Unresolved Relations)**
```python
response = await write_note(..., verbose=True)
# Check for forward references (unresolved relations)
forward_refs = []
for relation in response.get('relations', []):
if not relation.get('target_id'):
forward_refs.append(relation.get('to_name'))
if forward_refs:
# This is a feature, not an error! Inform the user about forward references
print(f"Note created with forward references to: {forward_refs}")
print("These will be automatically linked when those notes are created.")
# Optionally suggest creating those entities now
print("Would you like me to create any of these notes now to complete the connections?")
```
3. **Sync Issues**
```python
# If information seems outdated
activity = await recent_activity(timeframe="1 hour")
if not activity or not activity.primary_results:
print("It seems there haven't been recent updates. You might need to run 'basic-memory sync'.")
```
**Sync Issues:**
- If information seems outdated, suggest `basic-memory sync`
- Use `recent_activity()` to check if content is current
## Best Practices
@@ -395,4 +404,5 @@ Common issues to watch for:
- Offer to create summaries of scattered information
- Suggest potential missing relations: "I notice this might relate to [topic], would you like me to add that connection?"
Built with ♥️ by Basic Machines
+101 -19
View File
@@ -10,6 +10,25 @@ Basic Memory provides command line tools for managing your knowledge base. This
## Core Commands
### auth (New in v0.13.0)
Manage OAuth authentication for secure remote access:
```bash
# Test authentication setup
basic-memory auth test-auth
# Register OAuth client
basic-memory auth register-client
```
Supports multiple authentication providers:
- **Basic Provider**: For development and testing
- **Supabase Provider**: For production deployments
- **External Providers**: GitHub, Google integration framework
See [[OAuth Authentication Guide]] for complete setup instructions.
### sync
Keeps files and the knowledge graph in sync:
@@ -45,9 +64,9 @@ To change the properties, set the following values:
```
Thanks for using Basic Memory!
### import
### import (Enhanced in v0.13.0)
Imports external knowledge sources:
Imports external knowledge sources with support for project targeting:
```bash
# Claude conversations
@@ -59,12 +78,19 @@ basic-memory import claude projects
# ChatGPT history
basic-memory import chatgpt
# ChatGPT history
# Memory JSON format
basic-memory import memory-json /path/to/memory.json
# Import to specific project (v0.13.0)
basic-memory --project=work import claude conversations
```
> **Note**: After importing, run `basic-memory sync` to index the new files.
**New in v0.13.0:**
- **Project Targeting**: Import directly to specific projects
- **Real-time Sync**: Imported content available immediately
- **Unified Database**: All imports stored in centralized database
> **Note**: Changes sync automatically - no manual sync required in v0.13.0.
### status
Shows system status information:
@@ -81,28 +107,32 @@ basic-memory status --json
```
### project
### project (Enhanced in v0.13.0)
Create multiple projects to manage your knowledge.
Manage multiple projects with the new unified database architecture. Projects can now be switched instantly during conversations without restart.
```bash
# List all configured projects
# List all configured projects with status
basic-memory project list
# Add a new project
basic-memory project add work ~/work-basic-memory
# Create a new project
basic-memory project create work ~/work-basic-memory
# Set the default project
basic-memory project default work
basic-memory project set-default work
# Remove a project (doesn't delete files)
basic-memory project remove personal
# Delete a project (doesn't delete files)
basic-memory project delete personal
# Show current project
basic-memory project current
# Show detailed project statistics
basic-memory project info
```
> Be sure to restart Claude Desktop after changing projects.
**New in v0.13.0:**
- **Unified Database**: All projects share a single database for better performance
- **Instant Switching**: Switch projects during conversations without restart
- **Enhanced Commands**: Updated project commands with better status information
- **Project Statistics**: Detailed info about entities, observations, and relations
#### Using Projects in Commands
@@ -122,6 +152,34 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable:
BASIC_MEMORY_PROJECT=work basic-memory sync
```
### tool (Enhanced in v0.13.0)
Direct access to MCP tools via CLI with new editing and file management capabilities:
```bash
# Create notes
basic-memory tool write-note --title "My Note" --content "Content here"
# Edit notes incrementally (v0.13.0)
echo "New content" | basic-memory tool edit-note --title "My Note" --operation append
# Move notes (v0.13.0)
basic-memory tool move-note --identifier "My Note" --destination "archive/my-note.md"
# Search notes
basic-memory tool search-notes --query "authentication"
# Project management (v0.13.0)
basic-memory tool list-projects
basic-memory tool switch-project --project-name "work"
```
**New in v0.13.0:**
- **edit-note**: Incremental editing (append, prepend, find/replace, section replace)
- **move-note**: File management with database consistency
- **Project tools**: list-projects, switch-project, get-current-project
- **Cross-project operations**: Use `--project` flag with any tool
### help
The full list of commands and help for each can be viewed with the `--help` argument.
@@ -144,10 +202,11 @@ The full list of commands and help for each can be viewed with the `--help` argu
│ --help Show this message and exit. │
╰───────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ────────────────────────────────────────────────────────────────────────────────╮
sync Sync knowledge files with the database.
│ status Show sync status between files and database.
reset Reset database (drop all tables and recreate).
mcp Run the MCP server for Claude Desktop integration.
auth OAuth authentication management (v0.13.0)
│ sync Sync knowledge files with the database
status Show sync status between files and database
reset Reset database (drop all tables and recreate)
│ mcp Run the MCP server for Claude Desktop integration │
│ import Import data from various sources │
│ tool Direct access to MCP tools via CLI │
│ project Manage multiple Basic Memory projects │
@@ -302,6 +361,29 @@ You can then use the `/mcp` command in the REPL:
• basic-memory: connected
```
## Version Management (New in v0.13.0)
Basic Memory v0.13.0 introduces automatic version management and multiple installation options:
```bash
# Stable releases
pip install basic-memory
# Beta/pre-releases
pip install basic-memory --pre
# Latest development builds (auto-published)
pip install basic-memory --pre --force-reinstall
# Check current version
basic-memory --version
```
**Version Types:**
- **Stable**: `0.13.0` (manual git tags)
- **Beta**: `0.13.0b1` (manual git tags)
- **Development**: `0.12.4.dev26+468a22f` (automatic from commits)
## Troubleshooting Common Issues
### Sync Conflicts
+92 -30
View File
@@ -20,14 +20,25 @@ The easiest way to install basic memory is via `uv`. See the [uv installation gu
### 1. Install Basic Memory
```bash
# Install with uv (recommended).
uv tool install basic-memory
**v0.13.0 offers multiple installation options:**
# Or with pip
pip install basic-memory
```bash
# Stable release (recommended)
uv tool install basic-memory
# or: pip install basic-memory
# Beta releases (new features, testing)
pip install basic-memory --pre
# Development builds (latest changes)
pip install basic-memory --pre --force-reinstall
```
**Version Information:**
- **Stable**: Latest tested release (e.g., `0.13.0`)
- **Beta**: Pre-release versions (e.g., `0.13.0b1`)
- **Development**: Auto-published from git commits (e.g., `0.12.4.dev26+468a22f`)
> **Important**: You need to install Basic Memory using one of the commands above to use the command line tools.
Using `uv tool install` will install the basic-memory package in a standalone virtual environment. See the [UV docs](https://docs.astral.sh/uv/concepts/tools/) for more info.
@@ -99,30 +110,49 @@ To disable realtime sync, you can update the config. See [[CLI Reference#sync]].
To update Basic Memory when new versions are released:
```bash
# Update with uv (recommended)
# Update stable release
uv tool upgrade basic-memory
# or: pip install --upgrade basic-memory
# Or with pip
pip install --upgrade basic-memory
# Update to latest beta (v0.13.0)
pip install --upgrade basic-memory --pre
# Get latest development build
pip install --upgrade basic-memory --pre --force-reinstall
```
> **Note**: After updating, you'll need to restart Claude Desktop and your sync process for changes to take effect.
**v0.13.0 Update Benefits:**
- **Fluid project switching** during conversations
- **Advanced note editing** capabilities
- **Smart file management** with move operations
- **Enhanced search** with frontmatter tag support
### 5. Change the default project directory
> **Note**: After updating, restart Claude Desktop for changes to take effect. No sync restart needed in v0.13.0.
By default, Basic Memory will create a project in the `basic-memory` folder in your home directory. You can change this via the `project` [[CLI Reference#project|cli command]].
### 5. Multi-Project Setup (Enhanced in v0.13.0)
By default, Basic Memory creates a project in `~/basic-memory`. v0.13.0 introduces **fluid project management** - switch between projects instantly during conversations.
```
# Add a new project
basic-memory project add work ~/work-basic-memory
# Create a new project
basic-memory project create work ~/work-basic-memory
# Set the default project
basic-memory project default work
basic-memory project set-default work
# List all configured projects
basic-memory project list
# List all projects with status
basic-memory project list
# Get detailed project information
basic-memory project info
```
**New in v0.13.0:**
- **Instant switching**: Change projects during conversations without restart
- **Unified database**: All projects in single `~/.basic-memory/memory.db`
- **Better performance**: Optimized queries and reduced file I/O
- **Session context**: Maintains active project throughout conversations
## Troubleshooting Installation
### Common Issues
@@ -168,6 +198,7 @@ If you encounter permission errors:
---
title: Coffee Brewing Methods
permalink: coffee-brewing-methods
tags: [coffee, brewing, equipment] # v0.13.0: Now searchable!
---
# Coffee Brewing Methods
@@ -180,11 +211,10 @@ If you encounter permission errors:
- relates_to [[Other Coffee Topics]]
```
5. **Start the sync process** in a Terminal window (optional):
```bash
basic-memory sync --watch
```
Keep this running in the background.
**v0.13.0 Improvements:**
- **Real-time sync**: Changes appear immediately, no background sync needed
- **Searchable tags**: Frontmatter tags are now indexed for search
- **Better file organization**: Enhanced file management capabilities
## Using Special Prompts
@@ -250,14 +280,37 @@ Or directly reference notes using memory:// URLs:
You: "Take a look at memory://coffee-brewing-methods and let's discuss how to improve my technique."
```
### Building On Previous Knowledge
### Building On Previous Knowledge (Enhanced in v0.13.0)
Basic Memory enables continuous knowledge building:
1. **Reference previous discussions** in new conversations
2. **Add to existing notes** through conversations
3. **Create connections** between related topics
4. **Follow relationships** to build comprehensive context
2. **Edit notes incrementally** without rewriting entire documents
3. **Move and organize notes** as your knowledge base grows
4. **Switch between projects** instantly during conversations
5. **Search by tags** to find related content quickly
6. **Create connections** between related topics
7. **Follow relationships** to build comprehensive context
### v0.13.0 Workflow Examples
**Incremental Editing:**
```
You: "Add a section about espresso to my coffee brewing notes"
Claude: [Uses edit_note to append new section]
```
**File Organization:**
```
You: "Move my old meeting notes to an archive folder"
Claude: [Uses move_note with database consistency]
```
**Project Switching:**
```
You: "Switch to my work project and show recent activity"
Claude: [Switches projects and shows work-specific content]
```
## Importing Existing Conversations
@@ -271,17 +324,24 @@ basic-memory import claude conversations
basic-memory import chatgpt
```
After importing, the changes will be synced. Initial syncs may take a few moments. You can see info about your project by running `basic-memrory project info`.
After importing, changes sync automatically in real-time. You can see project statistics by running `basic-memory project info`.
## Quick Tips
- Basic Memory will sync changes from your project in real time.
### General Usage
- Basic Memory syncs changes in real-time (no manual sync needed)
- Use special prompts (Continue Conversation, Recent Activity, Search) to start contextual discussions
- Build connections between notes for a richer knowledge graph
- Use direct `memory://` URLs with a permalink when you need precise context. See [[User Guide#Using memory // URLs]]
- Use git to version control your knowledge base (git integration is on the roadmap)
- Use direct `memory://` URLs with permalinks for precise context
- Review and edit AI-generated notes for accuracy
### v0.13.0 Features
- **Switch projects instantly**: "Switch to my work project" - no restart needed
- **Edit notes incrementally**: "Add a section about..." instead of rewriting
- **Organize with moves**: "Move this to my archive folder" with database consistency
- **Search by tags**: Frontmatter tags are now searchable
- **Try beta builds**: `pip install basic-memory --pre` for latest features
## Next Steps
After getting started, explore these areas:
@@ -290,4 +350,6 @@ After getting started, explore these areas:
2. **Understand the [[Knowledge Format]]** to learn how knowledge is structured
3. **Set up [[Obsidian Integration]]** for visual knowledge navigation
4. **Learn about [[Canvas]]** visualizations for mapping concepts
5. **Review the [[CLI Reference]]** for command line tools
5. **Review the [[CLI Reference]]** for command line tools
6. **Explore [[OAuth Authentication Guide]]** for secure remote access (v0.13.0)
7. **Set up multiple projects** for different knowledge areas (v0.13.0)
+127 -26
View File
@@ -388,6 +388,59 @@ Maintain context for complex projects over time:
## Advanced Features
### Note Editing (New in v0.13.0)
**Edit notes incrementally without rewriting entire documents:**
```
💬 "Add a new section about deployment to my API documentation"
🤖 [Uses edit_note to append new section]
💬 "Update the date at the top of my meeting notes"
🤖 [Uses edit_note to prepend new timestamp]
💬 "Replace the implementation section in my design doc"
🤖 [Uses edit_note to replace specific section]
```
Available editing operations:
- **Append**: Add content to end of notes
- **Prepend**: Add content to beginning of notes
- **Replace Section**: Replace content under specific headers
- **Find & Replace**: Simple text replacements with validation
### File Management (New in v0.13.0)
**Move and organize notes with full database consistency:**
```
💬 "Move my old meeting notes to the archive folder"
🤖 [Uses move_note with automatic folder creation and database updates]
💬 "Reorganize my project files into a better structure"
🤖 [Moves files while maintaining search indexes and links]
```
Move operations include:
- **Database Consistency**: Updates file paths, permalinks, and checksums
- **Search Reindexing**: Maintains search functionality after moves
- **Folder Creation**: Automatically creates destination directories
- **Project Isolation**: Moves are contained within the current project
- **Rollback Protection**: Ensures data integrity during failed operations
### Enhanced Search (New in v0.13.0)
**Frontmatter tags are now searchable:**
```yaml
---
title: Coffee Brewing Methods
tags: [coffee, brewing, equipment]
---
```
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
### Importing External Knowledge
Import existing conversations:
@@ -398,9 +451,12 @@ basic-memory import claude conversations
# From ChatGPT
basic-memory import chatgpt
# Target specific projects (v0.13.0)
basic-memory --project=work import claude conversations
```
After importing, run `basic-memory sync` to index everything.
After importing, changes sync automatically in real-time.
### Obsidian Integration
@@ -460,12 +516,47 @@ basic-memory import claude conversations
basic-memory import chatgpt
```
## Multiple Projects
## Multiple Projects (v0.13.0)
Basic Memory supports managing multiple separate knowledge bases through projects. This feature allows you to maintain
separate knowledge graphs for different purposes (e.g., personal notes, work projects, research topics).
Basic Memory v0.13.0 introduces **fluid project management** - the ability to switch between projects instantly during conversations without restart. This allows you to maintain separate knowledge graphs for different purposes while seamlessly switching between them.
Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config.json`
### Instant Project Switching (New in v0.13.0)
**Switch projects during conversations:**
```
💬 "What projects do I have?"
🤖 Available projects:
• main (current, default)
• work-notes
• personal-journal
• code-snippets
💬 "Switch to work-notes"
🤖 ✓ Switched to work-notes project
Project Summary:
• 47 entities
• 125 observations
• 23 relations
💬 "What did I work on yesterday?"
🤖 [Shows recent activity from work-notes project]
```
### Project-Specific Operations (New in v0.13.0)
Some MCP tools support optional project parameters for targeting specific projects:
```
💬 "Create a note about this meeting in my personal-notes project"
🤖 [Creates note in personal-notes project]
💬 "Switch to my work project"
🤖 [Switches project context, then all operations work within that project]
```
**Note**: Operations like search, move, and edit work within the currently active project. To work with content in different projects, switch to that project first or use the project parameter where supported.
### Managing Projects
@@ -474,16 +565,16 @@ Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config
basic-memory project list
# Add a new project
basic-memory project add work ~/work-basic-memory
basic-memory project create work ~/work-basic-memory
# Set the default project
basic-memory project default work
basic-memory project set-default work
# Remove a project (doesn't delete files)
basic-memory project remove personal
basic-memory project delete personal
# Show current project
basic-memory project current
# Show current project statistics
basic-memory project info
```
### Using Projects in Commands
@@ -504,23 +595,32 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable:
BASIC_MEMORY_PROJECT=work basic-memory sync
```
### Project Isolation
### Unified Database Architecture (New in v0.13.0)
Each project maintains:
Basic Memory v0.13.0 uses a unified database architecture:
- Its own collection of markdown files in the specified directory
- A separate SQLite database for that project
- Complete knowledge graph isolation from other projects
- **Single Database**: All projects share `~/.basic-memory/memory.db`
- **Project Isolation**: Proper data separation with project context
- **Better Performance**: Optimized queries and reduced file I/O
- **Easier Backup**: Single database file contains all project data
- **Session Context**: Maintains active project throughout conversations
## Workflow Tips
1. Run sync in watch mode for automatic updates
2. Use git for version control of your knowledge base
3. Review and edit AI-created content for accuracy
4. Periodically organize and refine your knowledge structure
5. Build rich connections between related ideas
6. Use forward references to plan future documentation
7. Start conversations with special prompts to leverage existing knowledge
### General Workflow
1. **Project Organization**: Use multiple projects to separate different areas (work, personal, research)
2. **Session Context**: Switch projects during conversations without restart (v0.13.0)
3. **Real-time Sync**: Changes sync automatically - no need to run watch mode
4. **Review Content**: Edit AI-created content for accuracy
5. **Build Connections**: Create rich relationships between related ideas
6. **Use Special Prompts**: Start conversations with context from your knowledge base
### v0.13.0 Workflow Enhancements
7. **Incremental Editing**: Use edit_note for small changes instead of rewriting entire documents
8. **File Organization**: Move and reorganize notes as your knowledge base grows
9. **Project-Specific Creation**: Create notes in specific projects using project parameters
10. **Search Tags**: Use frontmatter tags to improve content discoverability
11. **Project Statistics**: Monitor project growth and activity with project info commands
## Troubleshooting
@@ -528,9 +628,8 @@ Each project maintains:
If changes aren't showing up:
1. Verify `basic-memory sync --watch` is running
2. Run `basic-memory status` to check system state
3. Try a manual sync with `basic-memory sync`
1. Run `basic-memory status` to check system state
2. Try a manual sync with `basic-memory sync`
### Missing Content
@@ -553,4 +652,6 @@ If relations aren't working:
- implements [[Knowledge Format]] (How knowledge is structured)
- relates_to [[Getting Started with Basic Memory]] (Setup and first steps)
- relates_to [[Canvas]] (Creating visual knowledge maps)
- relates_to [[CLI Reference]] (Command line tools)
- relates_to [[CLI Reference]] (Command line tools)
- enhanced_in_v0.13.0 [[OAuth Authentication Guide]] (Production authentication)
- enhanced_in_v0.13.0 [[Project Management]] (Multi-project workflows)
@@ -0,0 +1,69 @@
---
title: Test Note Creation - Basic Functionality
type: note
permalink: testing/test-note-creation-basic-functionality
tags:
- '["testing"'
- '"core-functionality"'
- '"note-creation"]'
---
---
title: Test Note Creation - Basic Functionality
tags: [testing, core-functionality, note-creation, edited]
test_status: active
last_edited: 2025-06-01
---
# Test Note Creation - Basic Functionality
## Test Status: COMPREHENSIVE TESTING IN PROGRESS
Testing basic note creation with various content types and structures.
## Content Types Tested
- Plain text content ✓
- Markdown formatting **bold**, *italic*
- Lists:
- Bullet points
- Numbered items
- Code blocks: `inline code`
```python
# Block code
def test_function():
return "Hello, Basic Memory!"
```
## Special Characters
- Unicode: café, naïve, résumé
- Emojis: 🚀 🔬 📝
- Symbols: @#$%^&*()
## Frontmatter Testing
This note should have proper frontmatter parsing.
## Relations to Test
- connects_to [[Another Test Note]]
- validates [[Core Functionality Tests]]
## Observations
- [success] Note creation initiated
- [test] Content variety included
- [validation] Special characters included
## Edit Test Results
- [success] Note reading via title lookup ✓
- [success] Search functionality returns relevant results ✓
- [success] Special characters (unicode, emojis) preserved ✓
- [test] Now testing append edit operation ✓
## Performance Notes
- Note creation: Instantaneous
- Note reading: Fast response
- Search: Good relevance scoring
## Next Tests
- Edit operations (append, prepend, find_replace)
- Move operations
- Cross-project functionality
+13 -17
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.12.3"
dynamic = ["version"]
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
@@ -47,7 +47,7 @@ basic-memory = "basic_memory.cli.main:app"
bm = "basic_memory.cli.main:app"
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
@@ -79,6 +79,15 @@ dev-dependencies = [
"pyqt6>=6.8.1",
]
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
fallback-version = "0.0.0"
[tool.pyright]
include = ["src/"]
exclude = ["**/__pycache__"]
@@ -89,20 +98,6 @@ reportMissingTypeStubs = false
pythonVersion = "3.12"
[tool.semantic_release]
version_variables = [
"src/basic_memory/__init__.py:__version__",
]
version_toml = [
"pyproject.toml:project.version",
]
major_on_zero = false
branch = "main"
changelog_file = "CHANGELOG.md"
build_command = "pip install uv && uv build"
dist_path = "dist/"
upload_to_pypi = true
commit_message = "chore(release): {version} [skip ci]"
[tool.coverage.run]
concurrency = ["thread", "gevent"]
@@ -128,7 +123,8 @@ omit = [
"*/watch_service.py", # File system watching - complex integration testing
"*/background_sync.py", # Background processes
"*/cli/main.py", # CLI entry point
"*/mcp/tools/project_management.py", # Covered by integration tests
]
[tool.logfire]
ignore_no_config = true
ignore_no_config = true
+7 -1
View File
@@ -1,3 +1,9 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.12.3"
try:
from importlib.metadata import version
__version__ = version("basic-memory")
except Exception: # pragma: no cover
# Fallback if package not installed (e.g., during development)
__version__ = "0.0.0" # pragma: no cover
@@ -56,11 +56,6 @@ def upgrade() -> None:
);
""")
# Print instruction to manually reindex after migration
print("\n------------------------------------------------------------------")
print("IMPORTANT: After migration completes, manually run the reindex command:")
print("basic-memory sync")
print("------------------------------------------------------------------\n")
def downgrade() -> None:
+5 -2
View File
@@ -60,15 +60,18 @@ app = FastAPI(
# Include routers
app.include_router(knowledge.router, prefix="/{project}")
app.include_router(management.router, prefix="/{project}")
app.include_router(memory.router, prefix="/{project}")
app.include_router(resource.router, prefix="/{project}")
app.include_router(search.router, prefix="/{project}")
app.include_router(project.router, prefix="/{project}")
app.include_router(project.project_router, prefix="/{project}")
app.include_router(directory_router.router, prefix="/{project}")
app.include_router(prompt_router.router, prefix="/{project}")
app.include_router(importer_router.router, prefix="/{project}")
# Project resource router works accross projects
app.include_router(project.project_resource_router)
app.include_router(management.router)
# Auth routes are handled by FastMCP automatically when auth is enabled
@@ -1,6 +1,8 @@
"""Router for directory tree operations."""
from fastapi import APIRouter
from typing import List, Optional
from fastapi import APIRouter, Query
from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
from basic_memory.schemas.directory import DirectoryNode
@@ -27,3 +29,35 @@ async def get_directory_tree(
# Return the hierarchical tree
return tree
@router.get("/list", response_model=List[DirectoryNode])
async def list_directory(
directory_service: DirectoryServiceDep,
project_id: ProjectIdDep,
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: ID of the current project
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
+110 -24
View File
@@ -10,6 +10,10 @@ from basic_memory.deps import (
get_search_service,
SearchServiceDep,
LinkResolverDep,
ProjectPathDep,
FileServiceDep,
ProjectConfigDep,
AppConfigDep,
)
from basic_memory.schemas import (
EntityListResponse,
@@ -17,6 +21,7 @@ from basic_memory.schemas import (
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@@ -43,41 +48,31 @@ async def create_entity(
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="create_entity",
title=result.title,
permalink=result.permalink,
status_code=201,
f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
)
return result
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
async def create_or_update_entity(
project: ProjectPathDep,
permalink: Permalink,
data: Entity,
response: Response,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
file_service: FileServiceDep,
) -> EntityResponse:
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
logger.info(
"API request",
endpoint="create_or_update_entity",
permalink=permalink,
entity_type=data.entity_type,
title=data.title,
f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
)
# Validate permalink matches
if data.permalink != permalink:
logger.warning(
"API validation error",
endpoint="create_or_update_entity",
permalink=permalink,
data_permalink=data.permalink,
error="Permalink mismatch",
f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
)
raise HTTPException(
status_code=400,
@@ -93,16 +88,107 @@ async def create_or_update_entity(
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="create_or_update_entity",
title=result.title,
permalink=result.permalink,
created=created,
status_code=response.status_code,
f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
)
return result
@router.patch("/entities/{identifier:path}", response_model=EntityResponse)
async def edit_entity(
identifier: str,
data: EditEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
This endpoint allows for targeted edits without requiring the full entity content.
"""
logger.info(
f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
)
try:
# Edit the entity using the service
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 the updated entity
await search_service.index_entity(entity, background_tasks=background_tasks)
# Return the updated entity response
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="edit_entity",
identifier=identifier,
operation=data.operation,
permalink=result.permalink,
status_code=200,
)
return result
except Exception as e:
logger.error(f"Error editing entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/move")
async def move_entity(
data: MoveEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
project_config: ProjectConfigDep,
app_config: AppConfigDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Move an entity to a new file location with project consistency.
This endpoint moves a note to a different path while maintaining project
consistency and optionally updating permalinks based on configuration.
"""
logger.info(
f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
)
try:
# Move the entity using the service
moved_entity = await entity_service.move_entity(
identifier=data.identifier,
destination_path=data.destination_path,
project_config=project_config,
app_config=app_config,
)
# Get the moved entity to reindex it
entity = await entity_service.link_resolver.resolve_link(data.destination_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
logger.info(
"API response",
endpoint="move_entity",
identifier=data.identifier,
destination=data.destination_path,
status_code=200,
)
result = EntityResponse.model_validate(moved_entity)
return result
except Exception as e:
logger.error(f"Error moving entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Read endpoints
@@ -164,8 +250,8 @@ async def delete_entity(
# Delete the entity
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
# Remove from search index
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
# Remove from search index (entity, observations, and relations)
background_tasks.add_task(search_service.handle_delete, entity)
result = DeleteEntitiesResponse(deleted=deleted)
return result
@@ -188,4 +274,4 @@ async def delete_entities(
background_tasks.add_task(search_service.delete_by_permalink, permalink)
result = DeleteEntitiesResponse(deleted=deleted)
return result
return result
+86 -91
View File
@@ -8,17 +8,18 @@ from basic_memory.schemas import ProjectInfoResponse
from basic_memory.schemas.project_info import (
ProjectList,
ProjectItem,
ProjectSwitchRequest,
ProjectInfoRequest,
ProjectStatusResponse,
ProjectWatchStatus,
)
# Define the router - we'll combine stats and project operations
router = APIRouter(prefix="/project", tags=["project"])
# Router for resources in a specific project
project_router = APIRouter(prefix="/project", tags=["project"])
# Router for managing project resources
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
# Get project information (moved from project_info_router.py)
@router.get("/info", response_model=ProjectInfoResponse)
@project_router.get("/info", response_model=ProjectInfoResponse)
async def get_project_info(
project_service: ProjectServiceDep,
) -> ProjectInfoResponse:
@@ -26,8 +27,49 @@ async def get_project_info(
return await project_service.get_project_info()
# Update a project
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
project_name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information in configuration and database.
Args:
project_name: The name of the project to update
path: Optional new path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
"""
try: # pragma: no cover
# Get original project info for the response
old_project = ProjectItem(
name=project_name,
path=project_service.projects.get(project_name, ""),
)
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
# Get updated project info
updated_path = path if path else project_service.projects.get(project_name, "")
return ProjectStatusResponse(
message=f"Project '{project_name}' updated successfully",
status="success",
default=(project_name == project_service.default_project),
old_project=old_project,
new_project=ProjectItem(name=project_name, path=updated_path),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# List all available projects
@router.get("/projects", response_model=ProjectList)
@project_resource_router.get("/projects", response_model=ProjectList)
async def list_projects(
project_service: ProjectServiceDep,
) -> ProjectList:
@@ -36,32 +78,28 @@ async def list_projects(
Returns:
A list of all projects with metadata
"""
projects_dict = project_service.projects
projects = await project_service.list_projects()
default_project = project_service.default_project
current_project = project_service.current_project
project_items = []
for name, path in projects_dict.items():
project_items.append(
ProjectItem(
name=name,
path=path,
is_default=(name == default_project),
is_current=(name == current_project),
)
project_items = [
ProjectItem(
name=project.name,
path=project.path,
is_default=project.is_default or False,
)
for project in projects
]
return ProjectList(
projects=project_items,
default_project=default_project,
current_project=current_project,
)
# Add a new project
@router.post("/projects", response_model=ProjectStatusResponse)
@project_resource_router.post("/projects", response_model=ProjectStatusResponse)
async def add_project(
project_data: ProjectSwitchRequest,
project_data: ProjectInfoRequest,
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
"""Add a new project to configuration and database.
@@ -82,10 +120,8 @@ async def add_project(
message=f"Project '{project_data.name}' added successfully",
status="success",
default=project_data.set_default,
new_project=ProjectWatchStatus(
name=project_data.name,
path=project_data.path,
watch_status=None,
new_project=ProjectItem(
name=project_data.name, path=project_data.path, is_default=project_data.set_default
),
)
except ValueError as e: # pragma: no cover
@@ -93,7 +129,7 @@ async def add_project(
# Remove a project
@router.delete("/projects/{name}", response_model=ProjectStatusResponse)
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
async def remove_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to remove"),
@@ -106,28 +142,26 @@ async def remove_project(
Returns:
Response confirming the project was removed
"""
try: # pragma: no cover
# Get project info before removal for the response
old_project = ProjectWatchStatus(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
)
try:
old_project = await project_service.get_project(name)
if not old_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
await project_service.remove_project(name)
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
return ProjectStatusResponse(
message=f"Project '{name}' removed successfully",
status="success",
default=False,
old_project=old_project,
old_project=ProjectItem(name=old_project.name, path=old_project.path),
new_project=None,
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Set a project as default
@router.put("/projects/{name}/default", response_model=ProjectStatusResponse)
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
async def set_default_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to set as default"),
@@ -140,78 +174,39 @@ async def set_default_project(
Returns:
Response confirming the project was set as default
"""
try: # pragma: no cover
try:
# Get the old default project
old_default = project_service.default_project
old_project = None
if old_default != name:
old_project = ProjectWatchStatus(
name=old_default,
path=project_service.projects.get(old_default, ""),
watch_status=None,
default_name = project_service.default_project
default_project = await project_service.get_project(default_name)
if not default_project: # pragma: no cover
raise HTTPException( # pragma: no cover
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
)
# get the new project
new_default_project = await project_service.get_project(name)
if not new_default_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
await project_service.set_default_project(name)
return ProjectStatusResponse(
message=f"Project '{name}' set as default successfully",
status="success",
default=True,
old_project=old_project,
new_project=ProjectWatchStatus(
old_project=ProjectItem(name=default_name, path=default_project.path),
new_project=ProjectItem(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
path=new_default_project.path,
is_default=True,
),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Update a project
@router.patch("/projects/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information in configuration and database.
Args:
name: The name of the project to update
path: Optional new path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
"""
try: # pragma: no cover
# Get original project info for the response
old_project = ProjectWatchStatus(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
)
await project_service.update_project(name, updated_path=path, is_active=is_active)
# Get updated project info
updated_path = path if path else project_service.projects.get(name, "")
return ProjectStatusResponse(
message=f"Project '{name}' updated successfully",
status="success",
default=(name == project_service.default_project),
old_project=old_project,
new_project=ProjectWatchStatus(name=name, path=updated_path, watch_status=None),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Synchronize projects between config and database
@router.post("/sync", response_model=ProjectStatusResponse)
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
async def synchronize_projects(
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
+18 -19
View File
@@ -2,6 +2,9 @@ from typing import Optional
import typer
from basic_memory.config import get_project_config
from basic_memory.mcp.project_session import session
def version_callback(value: bool) -> None:
"""Show version and exit."""
@@ -39,25 +42,6 @@ def app_callback(
) -> None:
"""Basic Memory - Local-first personal knowledge management."""
# We use the project option to set the BASIC_MEMORY_PROJECT environment variable
# The config module will pick this up when loading
if project: # pragma: no cover
import os
import importlib
from basic_memory import config as config_module
# Set the environment variable
os.environ["BASIC_MEMORY_PROJECT"] = project
# Reload the config module to pick up the new project
importlib.reload(config_module)
# Update the local reference
global app_config
from basic_memory.config import app_config as new_config
app_config = new_config
# Run initialization for every command unless --version was specified
if not version and ctx.invoked_subcommand is not None:
from basic_memory.config import app_config
@@ -65,6 +49,21 @@ def app_callback(
ensure_initialization(app_config)
# Initialize MCP session with the specified project or default
if project: # pragma: no cover
# Use the project specified via --project flag
current_project_config = get_project_config(project)
session.set_current_project(current_project_config.name)
# Update the global config to use this project
from basic_memory.config import update_current_project
update_current_project(project)
else:
# Use the default project
current_project = app_config.default_project
session.set_current_project(current_project)
# Register sub-command groups
import_app = typer.Typer(help="Import data from various sources")
+4 -37
View File
@@ -10,7 +10,8 @@ from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.config import config
from basic_memory.mcp.tools.project_info import project_info
from basic_memory.mcp.project_session import session
from basic_memory.mcp.resources.project_info import project_info
import json
from datetime import datetime
@@ -35,7 +36,7 @@ def format_path(path: str) -> str:
"""Format a path for display, using ~ for home directory."""
home = str(Path.home())
if path.startswith(home):
return path.replace(home, "~", 1)
return path.replace(home, "~", 1) # pragma: no cover
return path
@@ -58,7 +59,7 @@ def list_projects() -> None:
for project in result.projects:
is_default = "" if project.is_default else ""
is_active = "" if project.is_current else ""
is_active = "" if session.get_current_project() == project.name else ""
table.add_row(project.name, format_path(project.path), is_default, is_active)
console.print(table)
@@ -148,40 +149,6 @@ def set_default_project(
console.print("[green]Project activated for current session[/green]")
@project_app.command("current")
def show_current_project() -> None:
"""Show the current project."""
# Use API to get current project
project_url = config.project_url
try:
response = asyncio.run(call_get(client, f"{project_url}/project/projects"))
result = ProjectList.model_validate(response.json())
# Find the current project from the API response
current_project = result.current_project
default_project = result.default_project
# Find the project details in the list
for project in result.projects:
if project.name == current_project:
console.print(f"Current project: [cyan]{project.name}[/cyan]")
console.print(f"Path: [green]{format_path(project.path)}[/green]")
# Use app_config for database_path, not project config
from basic_memory.config import app_config
console.print(
f"Database: [blue]{format_path(str(app_config.app_database_path))}[/blue]"
)
console.print(f"Default project: [yellow]{default_project}[/yellow]")
break
except Exception as e:
console.print(f"[red]Error getting current project: {str(e)}[/red]")
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
raise typer.Exit(1)
@project_app.command("sync")
def synchronize_projects() -> None:
"""Synchronize projects between configuration file and database."""
+46 -12
View File
@@ -9,10 +9,12 @@ from typing import Any, Dict, Literal, Optional, List
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from setuptools.command.setopt import config_file
import basic_memory
from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = ".basic-memory"
@@ -147,7 +149,11 @@ class ConfigManager:
def __init__(self) -> None:
"""Initialize the configuration manager."""
self.config_dir = Path.home() / DATA_DIR_NAME
home = os.getenv("HOME", Path.home())
if isinstance(home, str):
home = Path(home)
self.config_dir = home / DATA_DIR_NAME
self.config_file = self.config_dir / CONFIG_FILE_NAME
# Ensure config directory exists
@@ -156,9 +162,6 @@ class ConfigManager:
# Load or create configuration
self.config = self.load_config()
# Current project context for the session
self.current_project_id: int
def load_config(self) -> BasicMemoryConfig:
"""Load configuration from file or create default."""
if self.config_file.exists():
@@ -177,7 +180,7 @@ class ConfigManager:
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
try:
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
@@ -192,7 +195,7 @@ class ConfigManager:
"""Get the default project name."""
return self.config.default_project
def add_project(self, name: str, path: str) -> None:
def add_project(self, name: str, path: str) -> ProjectConfig:
"""Add a new project to the configuration."""
if name in self.config.projects: # pragma: no cover
raise ValueError(f"Project '{name}' already exists")
@@ -203,6 +206,7 @@ class ConfigManager:
self.config.projects[name] = str(project_path)
self.save_config(self.config)
return ProjectConfig(name=name, home=project_path)
def remove_project(self, name: str) -> None:
"""Remove a project from the configuration."""
@@ -224,15 +228,36 @@ class ConfigManager:
self.save_config(self.config)
def get_project_config() -> ProjectConfig:
"""Get the project configuration for the current session."""
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
"""
Get the project configuration for the current session.
If project_name is provided, it will be used instead of the default project.
"""
# Get project name from environment variable or use provided name or default
env_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
actual_project_name = env_project_name or config_manager.default_project
actual_project_name = None
# load the config from file
global app_config
app_config = config_manager.load_config()
# Get project name from environment variable
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
if os_project_name: # pragma: no cover
logger.warning(
f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}"
)
actual_project_name = project_name
# if the project_name is passed in, use it
elif not project_name:
# use default
actual_project_name = app_config.default_project
else: # pragma: no cover
actual_project_name = project_name
# the config contains a dict[str,str] of project names and absolute paths
project_path = config_manager.projects.get(actual_project_name)
assert actual_project_name is not None, "actual_project_name cannot be None"
project_path = app_config.projects.get(actual_project_name)
if not project_path: # pragma: no cover
raise ValueError(f"Project '{actual_project_name}' not found")
@@ -249,6 +274,15 @@ app_config: BasicMemoryConfig = config_manager.config
config: ProjectConfig = get_project_config()
def update_current_project(project_name: str) -> None:
"""Update the global config to use a different project.
This is used by the CLI when --project flag is specified.
"""
global config
config = get_project_config(project_name) # pragma: no cover
# setup logging to a single log file in user home directory
user_home = Path.home()
log_dir = user_home / DATA_DIR_NAME
+33 -4
View File
@@ -1,6 +1,7 @@
"""Dependency injection functions for basic-memory services."""
from typing import Annotated
from loguru import logger
from fastapi import Depends, HTTPException, Path, status
from sqlalchemy.ext.asyncio import (
@@ -8,9 +9,10 @@ from sqlalchemy.ext.asyncio import (
AsyncEngine,
async_sessionmaker,
)
import pathlib
from basic_memory import db
from basic_memory.config import ProjectConfig, config, BasicMemoryConfig
from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.importers import (
ChatGPTImporter,
ClaudeConversationsImporter,
@@ -44,8 +46,30 @@ AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma:
## project
def get_project_config() -> ProjectConfig: # pragma: no cover
return config
async def get_project_config(
project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
) -> ProjectConfig: # pragma: no cover
"""Get the current project referenced from request state.
Args:
request: The current request object
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_permalink(str(project))
if project_obj:
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
# Not found
raise HTTPException( # pragma: no cover
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
)
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
@@ -203,7 +227,12 @@ MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_process
async def get_file_service(
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
) -> FileService:
return FileService(project_config.home, markdown_processor)
logger.debug(
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
)
file_service = FileService(project_config.home, markdown_processor)
logger.debug(f"Created FileService for project: {file_service} ")
return file_service
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
+5 -2
View File
@@ -92,7 +92,6 @@ class EntityParser:
async def parse_file(self, path: Path | str) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""
# TODO move to api endpoint to check if absolute path was requested
# Check if the path is already absolute
if (
isinstance(path, Path)
@@ -101,12 +100,16 @@ class EntityParser:
):
absolute_path = Path(path)
else:
absolute_path = self.base_path / path
absolute_path = self.get_file_path(path)
# Parse frontmatter and content using python-frontmatter
file_content = absolute_path.read_text(encoding="utf-8")
return await self.parse_file_content(absolute_path, file_content)
def get_file_path(self, path):
"""Get absolute path for a file using the base path for the project."""
return self.base_path / path
async def parse_file_content(self, absolute_path, file_content):
post = frontmatter.loads(file_content)
# Extract file stat info
+103
View File
@@ -0,0 +1,103 @@
"""Project session management for Basic Memory MCP server.
Provides simple in-memory project context for MCP tools, allowing users to switch
between projects during a conversation without restarting the server.
"""
from dataclasses import dataclass
from typing import Optional
from loguru import logger
from basic_memory.config import ProjectConfig, get_project_config
@dataclass
class ProjectSession:
"""Simple in-memory project context for MCP session.
This class manages the current project context that tools use when no explicit
project is specified. It's initialized with the default project from config
and can be changed during the conversation.
"""
current_project: Optional[str] = None
default_project: Optional[str] = None
def initialize(self, default_project: str) -> None:
"""Set the default project from config on startup.
Args:
default_project: The project name from configuration
"""
self.default_project = default_project
self.current_project = default_project
logger.info(f"Initialized project session with default project: {default_project}")
def get_current_project(self) -> str:
"""Get the currently active project name.
Returns:
The current project name, falling back to default, then 'main'
"""
return self.current_project or self.default_project or "main"
def set_current_project(self, project_name: str) -> None:
"""Set the current project context.
Args:
project_name: The project to switch to
"""
previous = self.current_project
self.current_project = project_name
logger.info(f"Switched project context: {previous} -> {project_name}")
def get_default_project(self) -> str:
"""Get the default project name from startup.
Returns:
The default project name, or 'main' if not set
"""
return self.default_project or "main" # pragma: no cover
def reset_to_default(self) -> None: # pragma: no cover
"""Reset current project back to the default project."""
self.current_project = self.default_project # pragma: no cover
logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover
# Global session instance
session = ProjectSession()
def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
"""Get the active project name for a tool call.
This is the main function tools should use to determine which project
to operate on.
Args:
project_override: Optional explicit project name from tool parameter
Returns:
The project name to use (override takes precedence over session context)
"""
if project_override: # pragma: no cover
project = get_project_config(project_override)
session.set_current_project(project_override)
return project
current_project = session.get_current_project()
return get_project_config(current_project)
def add_project_metadata(result: str, project_name: str) -> str:
"""Add project context as metadata footer for LLM awareness.
Args:
result: The tool result string
project_name: The project name that was used
Returns:
Result with project metadata footer
"""
return f"{result}\n\n<!-- Project: {project_name} -->" # pragma: no cover
@@ -2,14 +2,15 @@
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas import ProjectInfoResponse
@mcp.tool(
@mcp.resource(
uri="memory://project_info",
description="Get information and statistics about the current Basic Memory project.",
)
async def project_info() -> ProjectInfoResponse:
@@ -44,7 +45,8 @@ async def project_info() -> ProjectInfoResponse:
print(f"Basic Memory version: {info.system.version}")
"""
logger.info("Getting project info")
project_url = get_project_config().project_url
project_config = get_active_project()
project_url = project_config.project_url
# Call the API endpoint
response = await call_get(client, f"{project_url}/project/info")
+5
View File
@@ -15,6 +15,7 @@ from mcp.server.auth.settings import AuthSettings
from basic_memory.config import app_config
from basic_memory.services.initialization import initialize_app
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
from basic_memory.mcp.project_session import session
from basic_memory.mcp.external_auth_provider import (
create_github_provider,
create_google_provider,
@@ -37,6 +38,10 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
watch_task = await initialize_app(app_config)
# Initialize project session with default project
session.initialize(app_config.default_project)
try:
yield AppContext(watch_task=watch_task)
finally:
+20
View File
@@ -14,14 +14,34 @@ from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.project_management import (
list_projects,
switch_project,
get_current_project,
set_default_project,
create_project,
delete_project,
)
__all__ = [
"build_context",
"canvas",
"create_project",
"delete_note",
"delete_project",
"edit_note",
"get_current_project",
"list_directory",
"list_projects",
"move_note",
"read_content",
"read_note",
"recent_activity",
"search_notes",
"set_default_project",
"switch_project",
"write_note",
]
+8 -2
View File
@@ -4,10 +4,10 @@ from typing import Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -36,6 +36,7 @@ async def build_context(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
project: Optional[str] = None,
) -> GraphContext:
"""Get context needed to continue a discussion.
@@ -50,6 +51,7 @@ async def build_context(
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
project: Optional project name to build context from. If not provided, uses current active project.
Returns:
GraphContext containing:
@@ -69,11 +71,15 @@ async def build_context(
# Research the history of a feature
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
# Build context from specific project
build_context("memory://specs/search", project="work-project")
"""
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_get(
client,
+13 -3
View File
@@ -4,14 +4,14 @@ This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec
"""
import json
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.project_session import get_active_project
@mcp.tool(
@@ -22,6 +22,7 @@ async def canvas(
edges: List[Dict[str, Any]],
title: str,
folder: str,
project: Optional[str] = None,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -35,6 +36,7 @@ async def canvas(
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: The folder where the file should be saved
project: Optional project name to create canvas in. If not provided, uses current active project.
Returns:
A summary of the created canvas file
@@ -72,8 +74,16 @@ async def canvas(
]
}
```
Examples:
# Create canvas in current project
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
# Create canvas in specific project
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
+10 -5
View File
@@ -1,18 +1,19 @@
from basic_memory.config import get_project_config
from typing import Optional
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import DeleteEntitiesResponse
@mcp.tool(description="Delete a note by title or permalink")
async def delete_note(identifier: str) -> bool:
async def delete_note(identifier: str, project: Optional[str] = None) -> bool:
"""Delete a note from the knowledge base.
Args:
identifier: Note title or permalink
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
True if note was deleted, False otherwise
@@ -23,8 +24,12 @@ async def delete_note(identifier: str) -> bool:
# Delete by permalink
delete_note("notes/project-planning")
# Delete from specific project
delete_note("notes/project-planning", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
+297
View File
@@ -0,0 +1,297 @@
"""Edit note tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_patch
from basic_memory.schemas import EntityResponse
def _format_error_response(
error_message: str,
operation: str,
identifier: str,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> str:
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
# Entity not found errors
if "Entity not found" in error_message or "entity not found" in error_message.lower():
return f"""# Edit Failed - Note Not Found
The note with identifier '{identifier}' could not be found.
## Suggestions to try:
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
2. **Try different identifier formats**:
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
- Use `read_note()` first to verify the note exists and get the correct identifiers
## Alternative approach:
Use `write_note()` to create the note first, then edit it."""
# Find/replace specific errors
if operation == "find_replace":
if "Text to replace not found" in error_message:
return f"""# Edit Failed - Text Not Found
The text '{find_text}' was not found in the note '{identifier}'.
## Suggestions to try:
1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
2. **Check for exact matches**: The search is case-sensitive and must match exactly
3. **Try a broader search**: Search for just part of the text you want to replace
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
## Alternative approaches:
- Use `append` or `prepend` to add new content instead
- Use `replace_section` if you're trying to update a specific section"""
if "Expected" in error_message and "occurrences" in error_message:
# Extract the actual count from error message if possible
import re
match = re.search(r"found (\d+)", error_message)
actual_count = match.group(1) if match else "a different number of"
return f"""# Edit Failed - Wrong Replacement Count
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
## How to fix:
1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
## Example:
```
edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
```"""
# Section replacement errors
if operation == "replace_section" and "Multiple sections" in error_message:
return f"""# Edit Failed - Duplicate Section Headers
Multiple sections found with the same header in note '{identifier}'.
## How to fix:
1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
2. **Make headers unique**: Add more specific text to distinguish sections
3. **Use append instead**: Add content at the end rather than replacing a specific section
## Alternative approach:
Use `find_replace` to update specific text within the duplicate sections."""
# Generic server/request errors
if (
"Invalid request" in error_message or "malformed" in error_message.lower()
): # pragma: no cover
return f"""# Edit Failed - Request Error
There was a problem with the edit request to note '{identifier}': {error_message}.
## Common causes and fixes:
1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
3. **Empty or invalid content**: Check that your content is properly formatted
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
## Troubleshooting steps:
1. Verify the note exists: `read_note("{identifier}")`
2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
3. Try again with the correct identifier from the search results"""
# Fallback for other errors
return f"""# Edit Failed
Error editing note '{identifier}': {error_message}
## General troubleshooting:
1. **Verify the note exists**: Use `read_note("{identifier}")` to check
2. **Check your parameters**: Ensure all required parameters are provided correctly
3. **Read the note content first**: Use `read_note()` to understand the current structure
4. **Try a simpler operation**: Start with `append` if other operations fail
## Need help?
- Use `search_notes()` to find notes
- Use `read_note()` to examine content before editing
- Check that identifiers, section headers, and find_text match exactly"""
@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
)
async def edit_note(
identifier: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
project: Optional[str] = None,
) -> str:
"""Edit an existing markdown note in the knowledge base.
This tool allows you to make targeted changes to existing notes without rewriting the entire content.
It supports various operations for different editing scenarios.
Args:
identifier: The title, permalink, or memory:// URL of the note to edit
operation: The editing operation to perform:
- "append": Add content to the end of the note
- "prepend": Add content to the beginning of the note
- "find_replace": Replace occurrences of find_text with content
- "replace_section": Replace content under a specific markdown header
content: The content to add or use for replacement
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
find_text: For find_replace operation - the text to find and replace
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
A markdown formatted summary of the edit operation and resulting semantic content
Examples:
# Add new content to end of note
edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
# Add timestamp at beginning (frontmatter-aware)
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
# Update version number (single occurrence)
edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
# Update version in multiple places with validation
edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
# Replace text that appears multiple times - validate count first
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
# Replace implementation section
edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
# Replace subsection with more specific header
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
# Using different identifier formats
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
# Add new section to document
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
# Update status across document (expecting exactly 2 occurrences)
edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
# Replace text in a file, specifying project name
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
"""
active_project = get_active_project(project)
project_url = active_project.project_url
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
)
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
# Use the PATCH endpoint to edit the entity
try:
# Prepare the edit request data
edit_data = {
"operation": operation,
"content": content,
}
# Add optional parameters
if section:
edit_data["section"] = section
if find_text:
edit_data["find_text"] = find_text
if expected_replacements != 1: # Only send if different from default
edit_data["expected_replacements"] = str(expected_replacements)
# Call the PATCH endpoint
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json())
# Format summary
summary = [
f"# Edited note ({operation})",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Add operation-specific details
if operation == "append":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to end of note")
elif operation == "prepend":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to beginning of note")
elif operation == "find_replace":
# For find_replace, we can't easily count replacements from here
# since we don't have the original content, but the server handled it
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
# Count observations by category (reuse logic from write_note)
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
logger.info(
"MCP tool response",
tool="edit_note",
operation=operation,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
status_code=response.status_code,
)
return "\n".join(summary)
except Exception as e:
logger.error(f"Error editing note: {e}")
return _format_error_response(
str(e), operation, identifier, find_text, expected_replacements
)
@@ -0,0 +1,154 @@
"""List directory tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@mcp.tool(
description="List directory contents with filtering and depth control.",
)
async def list_directory(
dir_name: str = "/",
depth: int = 1,
file_name_glob: Optional[str] = None,
project: Optional[str] = None,
) -> str:
"""List directory contents from the knowledge base with optional filtering.
This tool provides 'ls' functionality for browsing the knowledge base directory structure.
It can list immediate children or recursively explore subdirectories with depth control,
and supports glob pattern filtering for finding specific files.
Args:
dir_name: Directory path to list (default: root "/")
Examples: "/", "/projects", "/research/ml"
depth: Recursion depth (1-10, default: 1 for immediate children only)
Higher values show subdirectory contents recursively
file_name_glob: Optional glob pattern for filtering file names
Examples: "*.md", "*meeting*", "project_*"
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
Formatted listing of directory contents with file metadata
Examples:
# List root directory contents
list_directory()
# List specific folder
list_directory(dir_name="/projects")
# Find all Python files
list_directory(file_name_glob="*.py")
# Deep exploration of research folder
list_directory(dir_name="/research", depth=3)
# Find meeting notes in projects folder
list_directory(dir_name="/projects", file_name_glob="*meeting*")
# Find meeting notes in a specific project
list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
"""
active_project = get_active_project(project)
project_url = active_project.project_url
# Prepare query parameters
params = {
"dir_name": dir_name,
"depth": str(depth),
}
if file_name_glob:
params["file_name_glob"] = file_name_glob
logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
# Call the API endpoint
response = await call_get(
client,
f"{project_url}/directory/list",
params=params,
)
nodes = response.json()
if not nodes:
filter_desc = ""
if file_name_glob:
filter_desc = f" matching '{file_name_glob}'"
return f"No files found in directory '{dir_name}'{filter_desc}"
# Format the results
output_lines = []
if file_name_glob:
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
else:
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
output_lines.append("")
# Group by type and sort
directories = [n for n in nodes if n["type"] == "directory"]
files = [n for n in nodes if n["type"] == "file"]
# Sort by name
directories.sort(key=lambda x: x["name"])
files.sort(key=lambda x: x["name"])
# Display directories first
for node in directories:
path_display = node["directory_path"]
output_lines.append(f"📁 {node['name']:<30} {path_display}")
# Add separator if we have both directories and files
if directories and files:
output_lines.append("")
# Display files with metadata
for node in files:
path_display = node["directory_path"]
title = node.get("title", "")
updated = node.get("updated_at", "")
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
if path_display.startswith("/"):
path_display = path_display[1:]
# Format date if available
date_str = ""
if updated:
try:
from datetime import datetime
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except Exception: # pragma: no cover
date_str = updated[:10] if len(updated) >= 10 else ""
# Create formatted line
file_line = f"📄 {node['name']:<30} {path_display}"
if title and title != node["name"]:
file_line += f" | {title}"
if date_str:
file_line += f" | {date_str}"
output_lines.append(file_line)
# Add summary
output_lines.append("")
total_count = len(directories) + len(files)
summary_parts = []
if directories:
summary_parts.append(
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
)
if files:
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
return "\n".join(output_lines)
+87
View File
@@ -0,0 +1,87 @@
"""Move note tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import EntityResponse
@mcp.tool(
description="Move a note to a new location, updating database and maintaining links.",
)
async def move_note(
identifier: str,
destination_path: str,
project: Optional[str] = None,
) -> str:
"""Move a note to a new file location within the same project.
Args:
identifier: Entity identifier (title, permalink, or memory:// URL)
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
project: Optional project name (defaults to current session project)
Returns:
Success message with move details
Examples:
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
Note: This operation moves notes within the specified project only. Moving notes
between different projects is not currently supported.
The move operation:
- Updates the entity's file_path in the database
- Moves the physical file on the filesystem
- Optionally updates permalinks if configured
- Re-indexes the entity for search
- Maintains all observations and relations
"""
logger.debug(f"Moving note: {identifier} to {destination_path}")
active_project = get_active_project(project)
project_url = active_project.project_url
# Prepare move request
move_data = {
"identifier": identifier,
"destination_path": destination_path,
"project": active_project.name,
}
# Call the move API endpoint
url = f"{project_url}/knowledge/move"
response = await call_post(client, url, json=move_data)
result = EntityResponse.model_validate(response.json())
# 10. Build success message
result_lines = [
"✅ Note moved successfully",
"",
f"📁 **{identifier}** → **{result.file_path}**",
f"🔗 Permalink: {result.permalink}",
"📊 Database and search index updated",
"",
f"<!-- Project: {active_project.name} -->",
]
# Return the response text which contains the formatted success message
result = "\n".join(result_lines)
# Log the operation
logger.info(
"Move note completed",
identifier=identifier,
destination_path=destination_path,
project=active_project.name,
status_code=response.status_code,
)
return result
@@ -0,0 +1,300 @@
"""Project management tools for Basic Memory MCP server.
These tools allow users to switch between projects, list available projects,
and manage project context during conversations.
"""
from fastmcp import Context
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import session, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
@mcp.tool()
async def list_projects(ctx: Context | None = None) -> str:
"""List all available projects with their status.
Shows all Basic Memory projects that are available, indicating which one
is currently active and which is the default.
Returns:
Formatted list of projects with status indicators
Example:
list_projects()
"""
if ctx: # pragma: no cover
await ctx.info("Listing all available projects")
# Get projects from API
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
current = session.get_current_project()
result = "Available projects:\n"
for project in project_list.projects:
indicators = []
if project.name == current:
indicators.append("current")
if project.is_default:
indicators.append("default")
if indicators:
result += f"{project.name} ({', '.join(indicators)})\n"
else:
result += f"{project.name}\n"
return add_project_metadata(result, current)
@mcp.tool()
async def switch_project(project_name: str, ctx: Context | None = None) -> str:
"""Switch to a different project context.
Changes the active project context for all subsequent tool calls.
Shows a project summary after switching successfully.
Args:
project_name: Name of the project to switch to
Returns:
Confirmation message with project summary
Example:
switch_project("work-notes")
switch_project("personal-journal")
"""
if ctx: # pragma: no cover
await ctx.info(f"Switching to project: {project_name}")
current_project = session.get_current_project()
try:
# Validate project exists by getting project list
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Check if project exists
project_exists = any(p.name == project_name for p in project_list.projects)
if not project_exists:
available_projects = [p.name for p in project_list.projects]
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
# Switch to the project
session.set_current_project(project_name)
current_project = session.get_current_project()
project_config = get_project_config(current_project)
# Get project info to show summary
try:
response = await call_get(client, f"{project_config.project_url}/project/info")
project_info = ProjectInfoResponse.model_validate(response.json())
result = f"✓ Switched to {project_name} project\n\n"
result += "Project Summary:\n"
result += f"{project_info.statistics.total_entities} entities\n"
result += f"{project_info.statistics.total_observations} observations\n"
result += f"{project_info.statistics.total_relations} relations\n"
except Exception as e:
# If we can't get project info, still confirm the switch
logger.warning(f"Could not get project info for {project_name}: {e}")
result = f"✓ Switched to {project_name} project\n\n"
result += "Project summary unavailable.\n"
return add_project_metadata(result, project_name)
except Exception as e:
logger.error(f"Error switching to project {project_name}: {e}")
# Revert to previous project on error
session.set_current_project(current_project)
raise e
@mcp.tool()
async def get_current_project(ctx: Context | None = None) -> str:
"""Show the currently active project and basic stats.
Displays which project is currently active and provides basic information
about it.
Returns:
Current project name and basic statistics
Example:
get_current_project()
"""
if ctx: # pragma: no cover
await ctx.info("Getting current project information")
current_project = session.get_current_project()
project_config = get_project_config(current_project)
result = f"Current project: {current_project}\n\n"
# get project stats
response = await call_get(client, f"{project_config.project_url}/project/info")
project_info = ProjectInfoResponse.model_validate(response.json())
result += f"{project_info.statistics.total_entities} entities\n"
result += f"{project_info.statistics.total_observations} observations\n"
result += f"{project_info.statistics.total_relations} relations\n"
default_project = session.get_default_project()
if current_project != default_project:
result += f"• Default project: {default_project}\n"
return add_project_metadata(result, current_project)
@mcp.tool()
async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
"""Set default project in config. Requires restart to take effect.
Updates the configuration to use a different default project. This change
only takes effect after restarting the Basic Memory server.
Args:
project_name: Name of the project to set as default
Returns:
Confirmation message about config update
Example:
set_default_project("work-notes")
"""
if ctx: # pragma: no cover
await ctx.info(f"Setting default project to: {project_name}")
# Call API to set default project
response = await call_put(client, f"/projects/{project_name}/default")
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
result += "Restart Basic Memory for this change to take effect:\n"
result += "basic-memory mcp\n"
if status_response.old_project:
result += f"\nPrevious default: {status_response.old_project.name}\n"
return add_project_metadata(result, session.get_current_project())
@mcp.tool()
async def create_project(
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
) -> str:
"""Create a new Basic Memory project.
Creates a new project with the specified name and path. The project directory
will be created if it doesn't exist. Optionally sets the new project as default.
Args:
project_name: Name for the new project (must be unique)
project_path: File system path where the project will be stored
set_default: Whether to set this project as the default (optional, defaults to False)
Returns:
Confirmation message with project details
Example:
create_project("my-research", "~/Documents/research")
create_project("work-notes", "/home/user/work", set_default=True)
"""
if ctx: # pragma: no cover
await ctx.info(f"Creating project: {project_name} at {project_path}")
# Create the project request
project_request = ProjectInfoRequest(
name=project_name, path=project_path, set_default=set_default
)
# Call API to create project
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
if status_response.new_project:
result += "Project Details:\n"
result += f"• Name: {status_response.new_project.name}\n"
result += f"• Path: {status_response.new_project.path}\n"
if set_default:
result += "• Set as default project\n"
result += "\nProject is now available for use.\n"
# If project was set as default, update session
if set_default:
session.set_current_project(project_name)
return add_project_metadata(result, session.get_current_project())
@mcp.tool()
async def delete_project(project_name: str, ctx: Context | None = None) -> str:
"""Delete a Basic Memory project.
Removes a project from the configuration and database. This does NOT delete
the actual files on disk - only removes the project from Basic Memory's
configuration and database records.
Args:
project_name: Name of the project to delete
Returns:
Confirmation message about project deletion
Example:
delete_project("old-project")
Warning:
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
if ctx: # pragma: no cover
await ctx.info(f"Deleting project: {project_name}")
current_project = session.get_current_project()
# Check if trying to delete current project
if project_name == current_project:
raise ValueError(
f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
)
# Get project info before deletion to validate it exists
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Check if project exists
project_exists = any(p.name == project_name for p in project_list.projects)
if not project_exists:
available_projects = [p.name for p in project_list.projects]
raise ValueError(
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
)
# Call API to delete project
response = await call_delete(client, f"/projects/{project_name}")
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
if status_response.old_project:
result += "Removed project details:\n"
result += f"• Name: {status_response.old_project.name}\n"
if hasattr(status_response.old_project, "path"):
result += f"• Path: {status_response.old_project.path}\n"
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
result += "Re-add the project to access its content again.\n"
return add_project_metadata(result, session.get_current_project())
+14 -8
View File
@@ -5,18 +5,19 @@ supporting various file types including text, images, and other binary files.
Files are read directly without any knowledge graph processing.
"""
from loguru import logger
from typing import Optional
import base64
import io
from loguru import logger
from PIL import Image as PILImage
from basic_memory.config import get_project_config
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.memory import memory_url_path
import base64
import io
from PIL import Image as PILImage
def calculate_target_params(content_length):
"""Calculate initial quality and size based on input file size"""
@@ -145,7 +146,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
@mcp.tool(description="Read a file's raw content by path or permalink")
async def read_content(path: str) -> dict:
async def read_content(path: str, project: Optional[str] = None) -> dict:
"""Read a file's raw content by path or permalink.
This tool provides direct access to file content in the knowledge base,
@@ -159,6 +160,7 @@ async def read_content(path: str) -> dict:
- A regular file path (docs/example.md)
- A memory URL (memory://docs/example)
- A permalink (docs/example)
project: Optional project name to read from. If not provided, uses current active project.
Returns:
A dictionary with the file content and metadata:
@@ -176,10 +178,14 @@ async def read_content(path: str) -> dict:
# Read using memory URL
content = await read_file("memory://docs/architecture")
# Read from specific project
content = await read_content("docs/example.md", project="work-project")
"""
logger.info("Reading file", path=path)
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
url = memory_url_path(path)
response = await call_get(client, f"{project_url}/resource/{url}")
+13 -5
View File
@@ -1,21 +1,24 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
description="Read a markdown note by title or permalink.",
)
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
async def read_note(
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
) -> str:
"""Read a markdown note from the knowledge base.
This tool finds and retrieves a note by its title, permalink, or content search,
@@ -27,6 +30,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
Can be a full memory:// URL, a permalink, a title, or search text
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
project: Optional project name to read from. If not provided, uses current active project.
Returns:
The full markdown content of the note if found, or helpful guidance if not found.
@@ -43,9 +47,13 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Read with pagination
read_note("Project Updates", page=2, page_size=5)
# Read from specific project
read_note("Meeting Notes", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
@@ -66,7 +74,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes(query=identifier, search_type="title")
title_results = await search_notes(query=identifier, search_type="title", project=project)
if title_results and title_results.results:
result = title_results.results[0] # Get the first/best match
@@ -90,7 +98,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes(query=identifier, search_type="text")
text_results = await search_notes(query=identifier, search_type="text", project=project)
# We didn't find a direct match, construct a helpful error message
if not text_results or not text_results.results:
@@ -1,13 +1,13 @@
"""Recent activity tool for Basic Memory MCP server."""
from typing import List, Union
from typing import List, Union, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext
from basic_memory.schemas.search import SearchItemType
@@ -32,6 +32,7 @@ async def recent_activity(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
project: Optional[str] = None,
) -> GraphContext:
"""Get recent activity across the knowledge base.
@@ -52,6 +53,7 @@ async def recent_activity(
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
project: Optional project name to get activity from. If not provided, uses current active project.
Returns:
GraphContext containing:
@@ -75,6 +77,9 @@ async def recent_activity(
# Look back further with more context
recent_activity(type="entity", depth=2, timeframe="2 weeks ago")
# Get activity from specific project
recent_activity(type="entity", project="work-project")
Notes:
- Higher depth values (>3) may impact performance with large result sets
- For focused queries, consider using build_context with a specific URI
@@ -115,7 +120,8 @@ async def recent_activity(
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_get(
client,
+8 -2
View File
@@ -4,10 +4,10 @@ from typing import List, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -22,6 +22,7 @@ async def search_notes(
types: Optional[List[str]] = None,
entity_types: Optional[List[str]] = None,
after_date: Optional[str] = None,
project: Optional[str] = None,
) -> SearchResponse:
"""Search across all content in the knowledge base.
@@ -37,6 +38,7 @@ async def search_notes(
types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d")
project: Optional project name to search in. If not provided, uses current active project.
Returns:
SearchResponse with results and pagination info
@@ -80,6 +82,9 @@ async def search_notes(
query="docs/meeting-*",
search_type="permalink"
)
# Search in specific project
results = await search_notes("meeting notes", project="work-project")
"""
# Create a SearchQuery object based on the parameters
search_query = SearchQuery()
@@ -104,7 +109,8 @@ async def search_notes(
if after_date:
search_query.after_date = after_date
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
logger.info(f"Searching for {search_query}")
response = await call_post(
+136 -12
View File
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
"""
import typing
from typing import Optional
from httpx import Response, URL, AsyncClient, HTTPStatusError
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -23,7 +24,9 @@ from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
def get_error_message(status_code: int, url: URL | str, method: str) -> str:
def get_error_message(
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
) -> str:
"""Get a friendly error message based on the HTTP status code.
Args:
@@ -103,6 +106,7 @@ async def call_get(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling GET '{url}' params: '{params}'")
error_message = None
try:
response = await client.get(
url,
@@ -120,7 +124,12 @@ async def call_get(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "GET")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PUT")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -138,8 +147,6 @@ async def call_get(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "GET")
raise ToolError(error_message) from e
@@ -183,6 +190,8 @@ async def call_put(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling PUT '{url}'")
error_message = None
try:
response = await client.put(
url,
@@ -204,7 +213,13 @@ async def call_put(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "PUT")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"] # pragma: no cover
else:
error_message = get_error_message(status_code, url, "PUT")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -221,9 +236,110 @@ async def call_put(
response.raise_for_status() # Will always raise since we're in the error case
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
raise ToolError(error_message) from e
async def call_patch(
client: AsyncClient,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
"""Make a PATCH request and handle errors appropriately.
Args:
client: The HTTPX AsyncClient to use
url: The URL to request
content: Request content
data: Form data
files: Files to upload
json: JSON data
params: Query parameters
headers: HTTP headers
cookies: HTTP cookies
auth: Authentication
follow_redirects: Whether to follow redirects
timeout: Request timeout
extensions: HTTPX extensions
Returns:
The HTTP response
Raises:
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling PATCH '{url}'")
try:
response = await client.patch(
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
if response.is_success:
return response
# Handle different status codes differently
status_code = response.status_code
# Try to extract specific error message from response body
try:
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
except Exception: # pragma: no cover
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
# Log at appropriate level based on status code
if 400 <= status_code < 500:
# Client errors: log as info except for 429 (Too Many Requests)
if status_code == 429: # pragma: no cover
logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
else:
logger.info(f"Client error: PATCH {url}: {error_message}")
else: # pragma: no cover
# Server errors: log as error
logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
# Raise a tool error with the friendly message
response.raise_for_status() # Will always raise since we're in the error case
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "PUT")
# Try to extract specific error message from response body
try:
response_data = e.response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
except Exception: # pragma: no cover
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
raise ToolError(error_message) from e
@@ -267,6 +383,7 @@ async def call_post(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling POST '{url}'")
error_message = None
try:
response = await client.post(
url=url,
@@ -289,7 +406,12 @@ async def call_post(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "POST")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "POST")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -307,8 +429,6 @@ async def call_post(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "POST")
raise ToolError(error_message) from e
@@ -344,6 +464,7 @@ async def call_delete(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling DELETE '{url}'")
error_message = None
try:
response = await client.delete(
url=url,
@@ -361,7 +482,12 @@ async def call_delete(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "DELETE")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"] # pragma: no cover
else:
error_message = get_error_message(status_code, url, "DELETE")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -379,6 +505,4 @@ async def call_delete(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "DELETE")
raise ToolError(error_message) from e
+10 -17
View File
@@ -1,16 +1,16 @@
"""Write note tool for Basic Memory MCP server."""
from typing import List, Union
from typing import List, Union, Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import EntityResponse
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags
from basic_memory.config import get_project_config
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -27,6 +27,7 @@ async def write_note(
content: str,
folder: str,
tags=None, # Remove type hint completely to avoid schema issues
project: Optional[str] = None,
) -> str:
"""Write a markdown note to the knowledge base.
@@ -56,6 +57,7 @@ async def write_note(
folder: the folder where the file should be saved
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
project: Optional project name to write to. If not provided, uses current active project.
Returns:
A markdown formatted summary of the semantic content, including:
@@ -65,12 +67,12 @@ async def write_note(
- Relation counts (resolved/unresolved)
- Tags if present
"""
logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
# Process tags using the helper function
tag_list = parse_tags(tags)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
@@ -79,11 +81,11 @@ async def write_note(
content=content,
entity_metadata=metadata,
)
project_url = get_project_config().project_url
print(f"project_url: {project_url}")
active_project = get_active_project(project)
project_url = active_project.project_url
# Create or update via knowledge API
logger.debug("Creating entity via API", permalink=entity.permalink)
logger.debug(f"Creating entity via API permalink={entity.permalink}")
url = f"{project_url}/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
@@ -125,15 +127,6 @@ async def write_note(
# Log the response with structured data
logger.info(
"MCP tool response",
tool="write_note",
action=action,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
resolved_relations=resolved,
unresolved_relations=unresolved,
status_code=response.status_code,
f"MCP tool response: tool=write_note action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
)
return "\n".join(summary)
@@ -130,26 +130,35 @@ class SearchRepository:
For FTS5:
- Special characters and phrases need to be quoted
- Terms with spaces or special chars need quotes
- Boolean operators (AND, OR, NOT) and parentheses are preserved
- Boolean operators (AND, OR, NOT) are preserved for complex queries
"""
if "*" in term:
return term
# Check for boolean operators - if present, return the term as is
boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
# Check for explicit boolean operators - if present, return the term as is
boolean_operators = [" AND ", " OR ", " NOT "]
if any(op in f" {term} " for op in boolean_operators):
return term
# List of special characters that need quoting (excluding *)
# List of FTS5 special characters that need escaping/quoting
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
# Check if term contains any special characters
needs_quotes = any(c in term for c in special_chars)
if needs_quotes:
# If the term already contains quotes, escape them and add a wildcard
term = term.replace('"', '""')
term = f'"{term}"*'
# Escape any existing quotes by doubling them
escaped_term = term.replace('"', '""')
# Quote the entire term to handle special characters safely
if is_prefix and not ("/" in term and term.endswith(".md")):
# For search terms (not file paths), add prefix matching
term = f'"{escaped_term}"*'
else:
# For file paths, use exact matching
term = f'"{escaped_term}"'
elif is_prefix:
# Only add wildcard for simple terms without special characters
term = f"{term}*"
return term
@@ -172,9 +181,8 @@ class SearchRepository:
# Handle text search for title and content
if search_text:
has_boolean = any(
op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
)
# Check for explicit boolean operators - only detect them in proper boolean contexts
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
if has_boolean:
# If boolean operators are present, use the raw query
@@ -189,9 +197,9 @@ class SearchRepository:
# Handle title match search
if title:
title_text = self._prepare_search_term(title.strip())
params["text"] = title_text
conditions.append("title MATCH :text")
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
params["title_text"] = title_text
conditions.append("title MATCH :title_text")
# Handle permalink exact search
if permalink:
+16 -18
View File
@@ -107,7 +107,7 @@ class ProjectInfoResponse(BaseModel):
system: SystemStatus = Field(description="System and service status information")
class ProjectSwitchRequest(BaseModel):
class ProjectInfoRequest(BaseModel):
"""Request model for switching projects."""
name: str = Field(..., description="Name of the project to switch to")
@@ -177,27 +177,12 @@ class ProjectWatchStatus(BaseModel):
)
class ProjectStatusResponse(BaseModel):
"""Response model for switching projects."""
message: str = Field(..., description="Status message about the project switch")
status: str = Field(..., description="Status of the switch (success or error)")
default: bool = Field(..., description="True if the project was set as the default")
old_project: Optional[ProjectWatchStatus] = Field(
None, description="Information about the project being switched from"
)
new_project: Optional[ProjectWatchStatus] = Field(
None, description="Information about the project being switched to"
)
class ProjectItem(BaseModel):
"""Simple representation of a project."""
name: str
path: str
is_default: bool
is_current: bool
is_default: bool = False
class ProjectList(BaseModel):
@@ -205,4 +190,17 @@ class ProjectList(BaseModel):
projects: List[ProjectItem]
default_project: str
current_project: str
class ProjectStatusResponse(BaseModel):
"""Response model for switching projects."""
message: str = Field(..., description="Status message about the project switch")
status: str = Field(..., description="Status of the switch (success or error)")
default: bool = Field(..., description="True if the project was set as the default")
old_project: Optional[ProjectItem] = Field(
None, description="Information about the project being switched from"
)
new_project: Optional[ProjectItem] = Field(
None, description="Information about the project being switched to"
)
+56 -2
View File
@@ -1,9 +1,9 @@
"""Request schemas for interacting with the knowledge graph."""
from typing import List, Optional, Annotated
from typing import List, Optional, Annotated, Literal
from annotated_types import MaxLen, MinLen
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from basic_memory.schemas.base import (
Relation,
@@ -56,3 +56,57 @@ class GetEntitiesRequest(BaseModel):
class CreateRelationsRequest(BaseModel):
relations: List[Relation]
class EditEntityRequest(BaseModel):
"""Request schema for editing an existing entity's content.
This allows for targeted edits without requiring the full entity content.
Supports various operation types for different editing scenarios.
"""
operation: Literal["append", "prepend", "find_replace", "replace_section"]
content: str
section: Optional[str] = None
find_text: Optional[str] = None
expected_replacements: int = 1
@field_validator("section")
@classmethod
def validate_section_for_replace_section(cls, v, info):
"""Ensure section is provided for replace_section operation."""
if info.data.get("operation") == "replace_section" and not v:
raise ValueError("section parameter is required for replace_section operation")
return v
@field_validator("find_text")
@classmethod
def validate_find_text_for_find_replace(cls, v, info):
"""Ensure find_text is provided for find_replace operation."""
if info.data.get("operation") == "find_replace" and not v:
raise ValueError("find_text parameter is required for find_replace operation")
return v
class MoveEntityRequest(BaseModel):
"""Request schema for moving an entity to a new file location.
This allows moving notes to different paths while maintaining project
consistency and optionally updating permalinks based on configuration.
"""
identifier: Annotated[str, MinLen(1), MaxLen(200)]
destination_path: Annotated[str, MinLen(1), MaxLen(500)]
project: Optional[str] = None
@field_validator("destination_path")
@classmethod
def validate_destination_path(cls, v):
"""Ensure destination path is relative and valid."""
if v.startswith("/"):
raise ValueError("destination_path must be relative, not absolute")
if ".." in v:
raise ValueError("destination_path cannot contain '..' path components")
if not v.strip():
raise ValueError("destination_path cannot be empty or whitespace only")
return v.strip()
+79 -1
View File
@@ -1,8 +1,9 @@
"""Directory service for managing file directories and tree structure."""
import fnmatch
import logging
import os
from typing import Dict
from typing import Dict, List, Optional
from basic_memory.repository import EntityRepository
from basic_memory.schemas.directory import DirectoryNode
@@ -87,3 +88,80 @@ class DirectoryService:
# Return the root node with its children
return root_node
async def list_directory(
self,
dir_name: str = "/",
depth: int = 1,
file_name_glob: Optional[str] = None,
) -> List[DirectoryNode]:
"""List directory contents with filtering and depth control.
Args:
dir_name: Directory path to list (default: root "/")
depth: Recursion depth (1 = immediate children only)
file_name_glob: Glob pattern for filtering file names
Returns:
List of DirectoryNode objects matching the criteria
"""
# Normalize directory path
if not dir_name.startswith("/"):
dir_name = f"/{dir_name}"
if dir_name != "/" and dir_name.endswith("/"):
dir_name = dir_name.rstrip("/")
# Get the full directory tree
root_tree = await self.get_directory_tree()
# Find the target directory node
target_node = self._find_directory_node(root_tree, dir_name)
if not target_node:
return []
# Collect nodes with depth and glob filtering
result = []
self._collect_nodes_recursive(target_node, result, depth, file_name_glob, 0)
return result
def _find_directory_node(
self, root: DirectoryNode, target_path: str
) -> Optional[DirectoryNode]:
"""Find a directory node by path in the tree."""
if root.directory_path == target_path:
return root
for child in root.children:
if child.type == "directory":
found = self._find_directory_node(child, target_path)
if found:
return found
return None
def _collect_nodes_recursive(
self,
node: DirectoryNode,
result: List[DirectoryNode],
max_depth: int,
file_name_glob: Optional[str],
current_depth: int,
) -> None:
"""Recursively collect nodes with depth and glob filtering."""
if current_depth >= max_depth:
return
for child in node.children:
# Apply glob filtering
if file_name_glob and not fnmatch.fnmatch(child.name, file_name_glob):
continue
# Add the child to results
result.append(child)
# Recurse into subdirectories if we haven't reached max depth
if child.type == "directory" and current_depth < max_depth:
self._collect_nodes_recursive(
child, result, max_depth, file_name_glob, current_depth + 1
)
+377 -2
View File
@@ -4,9 +4,12 @@ from pathlib import Path
from typing import List, Optional, Sequence, Tuple, Union
import frontmatter
import yaml
from loguru import logger
from sqlalchemy.exc import IntegrityError
from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter
from basic_memory.markdown import EntityMarkdown
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
@@ -114,8 +117,29 @@ class EntityService(BaseService[EntityModel]):
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
)
# Get unique permalink
permalink = await self.resolve_permalink(schema.permalink or file_path)
# Parse content frontmatter to check for user-specified permalink
content_markdown = None
if schema.content and has_frontmatter(schema.content):
content_frontmatter = parse_frontmatter(schema.content)
if "permalink" in content_frontmatter:
# Create a minimal EntityMarkdown object for permalink resolution
from basic_memory.markdown.schemas import EntityFrontmatter
frontmatter_metadata = {
"title": schema.title,
"type": schema.entity_type,
"permalink": content_frontmatter["permalink"],
}
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
content_markdown = EntityMarkdown(
frontmatter=frontmatter_obj,
content="", # content not needed for permalink resolution
observations=[],
relations=[],
)
# Get unique permalink (prioritizing content frontmatter)
permalink = await self.resolve_permalink(file_path, content_markdown)
schema._permalink = permalink
post = await schema_to_markdown(schema)
@@ -148,12 +172,47 @@ class EntityService(BaseService[EntityModel]):
# Read existing frontmatter from the file if it exists
existing_markdown = await self.entity_parser.parse_file(file_path)
# Parse content frontmatter to check for user-specified permalink
content_markdown = None
if schema.content and has_frontmatter(schema.content):
content_frontmatter = parse_frontmatter(schema.content)
if "permalink" in content_frontmatter:
# Create a minimal EntityMarkdown object for permalink resolution
from basic_memory.markdown.schemas import EntityFrontmatter
frontmatter_metadata = {
"title": schema.title,
"type": schema.entity_type,
"permalink": content_frontmatter["permalink"],
}
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
content_markdown = EntityMarkdown(
frontmatter=frontmatter_obj,
content="", # content not needed for permalink resolution
observations=[],
relations=[],
)
# Check if we need to update the permalink based on content frontmatter
new_permalink = entity.permalink # Default to existing
if content_markdown and content_markdown.frontmatter.permalink:
# Resolve permalink with the new content frontmatter
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
if resolved_permalink != entity.permalink:
new_permalink = resolved_permalink
# Update the schema to use the new permalink
schema._permalink = new_permalink
# Create post with new content from schema
post = await schema_to_markdown(schema)
# Merge new metadata with existing metadata
existing_markdown.frontmatter.metadata.update(post.metadata)
# Ensure the permalink in the metadata is the resolved one
if new_permalink != entity.permalink:
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
# Create a new post with merged metadata
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
@@ -325,3 +384,319 @@ class EntityService(BaseService[EntityModel]):
continue
return await self.repository.get_by_file_path(path)
async def edit_entity(
self,
identifier: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> EntityModel:
"""Edit an existing entity's content using various operations.
Args:
identifier: Entity identifier (permalink, title, etc.)
operation: The editing operation (append, prepend, find_replace, replace_section)
content: The content to add or use for replacement
section: For replace_section operation - the markdown header
find_text: For find_replace operation - the text to find and replace
expected_replacements: For find_replace operation - expected number of replacements (default: 1)
Returns:
The updated entity model
Raises:
EntityNotFoundError: If the entity cannot be found
ValueError: If required parameters are missing for the operation or replacement count doesn't match expected
"""
logger.debug(f"Editing entity: {identifier}, operation: {operation}")
# Find the entity using the link resolver
entity = await self.link_resolver.resolve_link(identifier)
if not entity:
raise EntityNotFoundError(f"Entity not found: {identifier}")
# Read the current file content
file_path = Path(entity.file_path)
current_content, _ = await self.file_service.read_file(file_path)
# Apply the edit operation
new_content = self.apply_edit_operation(
current_content, operation, content, section, find_text, expected_replacements
)
# Write the updated content back to the file
checksum = await self.file_service.write_file(file_path, new_content)
# Parse the updated file to get new observations/relations
entity_markdown = await self.entity_parser.parse_file(file_path)
# Update entity and its relationships
entity = await self.update_entity_and_observations(file_path, entity_markdown)
await self.update_entity_relations(str(file_path), entity_markdown)
# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
return entity
def apply_edit_operation(
self,
current_content: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> str:
"""Apply the specified edit operation to the current content."""
if operation == "append":
# Ensure proper spacing
if current_content and not current_content.endswith("\n"):
return current_content + "\n" + content
return current_content + content # pragma: no cover
elif operation == "prepend":
# Handle frontmatter-aware prepending
return self._prepend_after_frontmatter(current_content, content)
elif operation == "find_replace":
if not find_text:
raise ValueError("find_text is required for find_replace operation")
if not find_text.strip():
raise ValueError("find_text cannot be empty or whitespace only")
# Count actual occurrences
actual_count = current_content.count(find_text)
# Validate count matches expected
if actual_count != expected_replacements:
if actual_count == 0:
raise ValueError(f"Text to replace not found: '{find_text}'")
else:
raise ValueError(
f"Expected {expected_replacements} occurrences of '{find_text}', "
f"but found {actual_count}"
)
return current_content.replace(find_text, content)
elif operation == "replace_section":
if not section:
raise ValueError("section is required for replace_section operation")
if not section.strip():
raise ValueError("section cannot be empty or whitespace only")
return self.replace_section_content(current_content, section, content)
else:
raise ValueError(f"Unsupported operation: {operation}")
def replace_section_content(
self, current_content: str, section_header: str, new_content: str
) -> str:
"""Replace content under a specific markdown section header.
This method uses a simple, safe approach: when replacing a section, it only
replaces the immediate content under that header until it encounters the next
header of ANY level. This means:
- Replacing "# Header" replaces content until "## Subsection" (preserves subsections)
- Replacing "## Section" replaces content until "### Subsection" (preserves subsections)
- More predictable and safer than trying to consume entire hierarchies
Args:
current_content: The current markdown content
section_header: The section header to find and replace (e.g., "## Section Name")
new_content: The new content to replace the section with
Returns:
The updated content with the section replaced
Raises:
ValueError: If multiple sections with the same header are found
"""
# Normalize the section header (ensure it starts with #)
if not section_header.startswith("#"):
section_header = "## " + section_header
# First pass: count matching sections to check for duplicates
lines = current_content.split("\n")
matching_sections = []
for i, line in enumerate(lines):
if line.strip() == section_header.strip():
matching_sections.append(i)
# Handle multiple sections error
if len(matching_sections) > 1:
raise ValueError(
f"Multiple sections found with header '{section_header}'. "
f"Section replacement requires unique headers."
)
# If no section found, append it
if len(matching_sections) == 0:
logger.info(f"Section '{section_header}' not found, appending to end of document")
separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
return current_content + separator + section_header + "\n" + new_content
# Replace the single matching section
result_lines = []
section_line_idx = matching_sections[0]
i = 0
while i < len(lines):
line = lines[i]
# Check if this is our target section header
if i == section_line_idx:
# Add the section header and new content
result_lines.append(line)
result_lines.append(new_content)
i += 1
# Skip the original section content until next header or end
while i < len(lines):
next_line = lines[i]
# Stop consuming when we hit any header (preserve subsections)
if next_line.startswith("#"):
# We found another header - continue processing from here
break
i += 1
# Continue processing from the next header (don't increment i again)
continue
# Add all other lines (including subsequent sections)
result_lines.append(line)
i += 1
return "\n".join(result_lines)
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
"""Prepend content after frontmatter, preserving frontmatter structure."""
# Check if file has frontmatter
if has_frontmatter(current_content):
try:
# Parse and separate frontmatter from body
frontmatter_data = parse_frontmatter(current_content)
body_content = remove_frontmatter(current_content)
# Prepend content to the body
if content and not content.endswith("\n"):
new_body = content + "\n" + body_content
else:
new_body = content + body_content
# Reconstruct file with frontmatter + prepended body
yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
except Exception as e: # pragma: no cover
logger.warning(
f"Failed to parse frontmatter during prepend: {e}"
) # pragma: no cover
# Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
# No frontmatter or parsing failed - do simple prepend # pragma: no cover
if content and not content.endswith("\n"): # pragma: no cover
return content + "\n" + current_content # pragma: no cover
return content + current_content # pragma: no cover
async def move_entity(
self,
identifier: str,
destination_path: str,
project_config: ProjectConfig,
app_config: BasicMemoryConfig,
) -> EntityModel:
"""Move entity to new location with database consistency.
Args:
identifier: Entity identifier (title, permalink, or memory:// URL)
destination_path: New path relative to project root
project_config: Project configuration for file operations
app_config: App configuration for permalink update settings
Returns:
Success message with move details
Raises:
EntityNotFoundError: If the entity cannot be found
ValueError: If move operation fails due to validation or filesystem errors
"""
logger.debug(f"Moving entity: {identifier} to {destination_path}")
# 1. Resolve identifier to entity
entity = await self.link_resolver.resolve_link(identifier)
if not entity:
raise EntityNotFoundError(f"Entity not found: {identifier}")
current_path = entity.file_path
old_permalink = entity.permalink
# 2. Validate destination path format first
if not destination_path or destination_path.startswith("/") or not destination_path.strip():
raise ValueError(f"Invalid destination path: {destination_path}")
# 3. Validate paths
source_file = project_config.home / current_path
destination_file = project_config.home / destination_path
# Validate source exists
if not source_file.exists():
raise ValueError(f"Source file not found: {current_path}")
# Check if destination already exists
if destination_file.exists():
raise ValueError(f"Destination already exists: {destination_path}")
try:
# 4. Create destination directory if needed
destination_file.parent.mkdir(parents=True, exist_ok=True)
# 5. Move physical file
source_file.rename(destination_file)
logger.info(f"Moved file: {current_path} -> {destination_path}")
# 6. Prepare database updates
updates = {"file_path": destination_path}
# 7. Update permalink if configured
if app_config.update_permalinks_on_move:
# Generate new permalink from destination path
new_permalink = await self.resolve_permalink(destination_path)
# Update frontmatter with new permalink
await self.file_service.update_frontmatter(
destination_path, {"permalink": new_permalink}
)
updates["permalink"] = new_permalink
logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
# 8. Recalculate checksum
new_checksum = await self.file_service.compute_checksum(destination_path)
updates["checksum"] = new_checksum
# 9. Update database
updated_entity = await self.repository.update(entity.id, updates)
if not updated_entity:
raise ValueError(f"Failed to update entity in database: {entity.id}")
return updated_entity
except Exception as e:
# Rollback: try to restore original file location if move succeeded
if destination_file.exists() and not source_file.exists():
try:
destination_file.rename(source_file)
logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
except Exception as rollback_error: # pragma: no cover
logger.error(f"Failed to rollback file move: {rollback_error}")
# Re-raise the original error with context
raise ValueError(f"Move failed: {str(e)}") from e
+10 -10
View File
@@ -94,8 +94,8 @@ class FileService:
"""
try:
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
logger.debug(f"Checking file existence: path={path_obj}")
if path_obj.is_absolute():
return path_obj.exists()
else:
@@ -121,7 +121,7 @@ class FileService:
FileOperationError: If write fails
"""
# Convert string to Path if needed
path_obj = 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
try:
@@ -140,7 +140,7 @@ class FileService:
# Compute and return checksum
checksum = await file_utils.compute_checksum(content)
logger.debug("File write completed", path=str(full_path), checksum=checksum)
logger.debug(f"File write completed path={full_path}, {checksum=}")
return checksum
except Exception as e:
@@ -164,7 +164,7 @@ class FileService:
FileOperationError: If read fails
"""
# Convert string to Path if needed
path_obj = 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
try:
@@ -194,7 +194,7 @@ class FileService:
path: Path to delete (Path or string)
"""
# Convert string to Path if needed
path_obj = 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.unlink(missing_ok=True)
@@ -210,7 +210,7 @@ class FileService:
Checksum of updated file
"""
# Convert string to Path if needed
path_obj = 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
return await file_utils.update_frontmatter(full_path, updates)
@@ -227,7 +227,7 @@ class FileService:
FileError: If checksum computation fails
"""
# Convert string to Path if needed
path_obj = 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
try:
@@ -253,7 +253,7 @@ class FileService:
File statistics
"""
# Convert string to Path if needed
path_obj = 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
# get file timestamps
return full_path.stat()
@@ -268,7 +268,7 @@ class FileService:
MIME type of the file
"""
# Convert string to Path if needed
path_obj = 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
# get file timestamps
mime_type, _ = mimetypes.guess_type(full_path.name)
+14 -6
View File
@@ -15,10 +15,10 @@ class LinkResolver:
Uses a combination of exact matching and search-based resolution:
1. Try exact permalink match (fastest)
2. Try permalink pattern match (for wildcards)
3. Try exact title match
4. Fall back to search for fuzzy matching
5. Generate new permalink if no match found
2. Try exact title match
3. Try exact file path match
4. Try file path with .md extension (for folder/title patterns)
5. Fall back to search for fuzzy matching
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -52,11 +52,19 @@ class LinkResolver:
logger.debug(f"Found entity with path: {found_path.file_path}")
return found_path
# 4. Try file path with .md extension if not already present
if not clean_text.endswith(".md") and "/" in clean_text:
file_path_with_md = f"{clean_text}.md"
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
if found_path_md:
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md
# search if indicated
if use_search and "*" not in clean_text:
# 3. Fall back to search for fuzzy matching on title
# 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
results = await self.search_service.search(
query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]),
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
)
if results:
+35 -25
View File
@@ -4,12 +4,13 @@ import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
from typing import Dict, Optional, Sequence
from loguru import logger
from sqlalchemy import text
from basic_memory.config import ConfigManager, config, app_config
from basic_memory.config import config, app_config
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas import (
ActivityMetrics,
@@ -18,15 +19,17 @@ from basic_memory.schemas import (
SystemStatus,
)
from basic_memory.config import WATCH_STATUS_JSON
from basic_memory.utils import generate_permalink
from basic_memory.config import config_manager
class ProjectService:
"""Service for managing Basic Memory projects."""
def __init__(self, repository: Optional[ProjectRepository] = None):
repository: ProjectRepository
def __init__(self, repository: ProjectRepository):
"""Initialize the project service."""
super().__init__()
self.config_manager = ConfigManager()
self.repository = repository
@property
@@ -36,7 +39,7 @@ class ProjectService:
Returns:
Dict mapping project names to their file paths
"""
return self.config_manager.projects
return config_manager.projects
@property
def default_project(self) -> str:
@@ -45,7 +48,7 @@ class ProjectService:
Returns:
The name of the default project
"""
return self.config_manager.default_project
return config_manager.default_project
@property
def current_project(self) -> str:
@@ -54,7 +57,14 @@ class ProjectService:
Returns:
The name of the current project
"""
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
async def list_projects(self) -> Sequence[Project]:
return await self.repository.find_all()
async def get_project(self, name: str) -> Optional[Project]:
"""Get the file path for a project by name."""
return await self.repository.get_by_name(name)
async def add_project(self, name: str, path: str) -> None:
"""Add a new project to the configuration and database.
@@ -73,13 +83,13 @@ class ProjectService:
resolved_path = os.path.abspath(os.path.expanduser(path))
# First add to config file (this will validate the project doesn't exist)
self.config_manager.add_project(name, resolved_path)
project_config = config_manager.add_project(name, resolved_path)
# Then add to database
project_data = {
"name": name,
"path": resolved_path,
"permalink": name.lower().replace(" ", "-"),
"permalink": generate_permalink(project_config.name),
"is_active": True,
"is_default": False,
}
@@ -100,7 +110,7 @@ class ProjectService:
raise ValueError("Repository is required for remove_project")
# First remove from config (this will validate the project exists and is not default)
self.config_manager.remove_project(name)
config_manager.remove_project(name)
# Then remove from database
project = await self.repository.get_by_name(name)
@@ -122,7 +132,7 @@ class ProjectService:
raise ValueError("Repository is required for set_default_project")
# First update config file (this will validate the project exists)
self.config_manager.set_default_project(name)
config_manager.set_default_project(name)
# Then update database
project = await self.repository.get_by_name(name)
@@ -150,7 +160,7 @@ class ProjectService:
db_projects_by_name = {p.name: p for p in db_projects}
# Get all projects from configuration
config_projects = self.config_manager.projects
config_projects = config_manager.projects
# Add projects that exist in config but not in DB
for name, path in config_projects.items():
@@ -161,7 +171,7 @@ class ProjectService:
"path": path,
"permalink": name.lower().replace(" ", "-"),
"is_active": True,
"is_default": (name == self.config_manager.default_project),
"is_default": (name == config_manager.default_project),
}
await self.repository.create(project_data)
@@ -169,16 +179,16 @@ class ProjectService:
for name, project in db_projects_by_name.items():
if name not in config_projects:
logger.info(f"Adding project '{name}' to configuration")
self.config_manager.add_project(name, project.path)
config_manager.add_project(name, project.path)
# Make sure default project is synchronized
db_default = next((p for p in db_projects if p.is_default), None)
config_default = self.config_manager.default_project
config_default = config_manager.default_project
if db_default and db_default.name != config_default:
# Update config to match DB default
logger.info(f"Updating default project in config to '{db_default.name}'")
self.config_manager.set_default_project(db_default.name)
config_manager.set_default_project(db_default.name)
elif not db_default and config_default in db_projects_by_name:
# Update DB to match config default
logger.info(f"Updating default project in database to '{config_default}'")
@@ -204,7 +214,7 @@ class ProjectService:
raise ValueError("Repository is required for update_project")
# Validate project exists in config
if name not in self.config_manager.projects:
if name not in config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")
# Get project from database
@@ -218,10 +228,10 @@ class ProjectService:
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
# Update in config
projects = self.config_manager.config.projects.copy()
projects = config_manager.config.projects.copy()
projects[name] = resolved_path
self.config_manager.config.projects = projects
self.config_manager.save_config(self.config_manager.config)
config_manager.config.projects = projects
config_manager.save_config(config_manager.config)
# Update in database
project.path = resolved_path
@@ -242,7 +252,7 @@ class ProjectService:
if active_projects:
new_default = active_projects[0]
await self.repository.set_as_default(new_default.id)
self.config_manager.set_default_project(new_default.name)
config_manager.set_default_project(new_default.name)
logger.info(
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
)
@@ -274,11 +284,11 @@ class ProjectService:
db_projects_by_name = {p.name: p for p in db_projects}
# Get default project info
default_project = self.config_manager.default_project
default_project = config_manager.default_project
# Convert config projects to include database info
enhanced_projects = {}
for name, path in self.config_manager.projects.items():
for name, path in config_manager.projects.items():
db_project = db_projects_by_name.get(name)
enhanced_projects[name] = {
"path": path,
@@ -535,4 +545,4 @@ class ProjectService:
database_size=db_size_readable,
watch_status=watch_status,
timestamp=datetime.now(),
)
)
@@ -1,5 +1,6 @@
"""Service for search operations."""
import ast
from datetime import datetime
from typing import List, Optional, Set
@@ -117,6 +118,38 @@ class SearchService:
return variants
def _extract_entity_tags(self, entity: Entity) -> List[str]:
"""Extract tags from entity metadata for search indexing.
Handles multiple tag formats:
- List format: ["tag1", "tag2"]
- String format: "['tag1', 'tag2']" or "[tag1, tag2]"
- Empty: [] or "[]"
Returns a list of tag strings for search indexing.
"""
if not entity.entity_metadata or "tags" not in entity.entity_metadata:
return []
tags = entity.entity_metadata["tags"]
# Handle list format (preferred)
if isinstance(tags, list):
return [str(tag) for tag in tags if tag]
# Handle string format (legacy)
if isinstance(tags, str):
try:
# Parse string representation of list
parsed_tags = ast.literal_eval(tags)
if isinstance(parsed_tags, list):
return [str(tag) for tag in parsed_tags if tag]
except (ValueError, SyntaxError):
# If parsing fails, treat as single tag
return [tags] if tags.strip() else []
return [] # pragma: no cover
async def index_entity(
self,
entity: Entity,
@@ -201,6 +234,11 @@ class SearchService:
content_stems.extend(self._generate_variants(entity.file_path))
# Add entity tags from frontmatter to search content
entity_tags = self._extract_entity_tags(entity)
if entity_tags:
content_stems.extend(entity_tags)
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
# Index entity
@@ -286,3 +324,32 @@ class SearchService:
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index."""
await self.repository.delete_by_entity_id(entity_id)
async def handle_delete(self, entity: Entity):
"""Handle complete entity deletion from search index including observations and relations.
This replicates the logic from sync_service.handle_delete() to properly clean up
all search index entries for an entity and its related data.
"""
logger.debug(
f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
)
# Clean up search index - same logic as sync_service.handle_delete()
permalinks = (
[entity.permalink]
+ [o.permalink for o in entity.observations]
+ [r.permalink for r in entity.outgoing_relations]
)
logger.debug(
f"Deleting search index entries for entity_id={entity.id}, "
f"index_entries={len(permalinks)}"
)
for permalink in permalinks:
if permalink:
await self.delete_by_permalink(permalink)
else:
await self.delete_by_entity_id(entity.id)
+2 -2
View File
@@ -379,7 +379,7 @@ class SyncService:
updates = {"file_path": new_path}
# If configured, also update permalink to match new path
if self.app_config.update_permalinks_on_move:
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(new_path):
# generate new permalink value
new_permalink = await self.entity_service.resolve_permalink(new_path)
@@ -505,4 +505,4 @@ class SyncService:
f"duration_ms={duration_ms}"
)
return result
return result
+231
View File
@@ -0,0 +1,231 @@
"""
Shared fixtures for integration tests.
Integration tests verify the complete flow: MCP Client → MCP Server → FastAPI → Database.
Unlike unit tests which use in-memory databases and mocks, integration tests use real SQLite
files and test the full application stack to ensure all components work together correctly.
## Architecture
The integration test setup creates this flow:
```
Test → MCP Client → MCP Server → HTTP Request (ASGITransport) → FastAPI App → Database
Dependency overrides
point to test database
```
## Key Components
1. **Real SQLite Database**: Uses `DatabaseType.FILESYSTEM` with actual SQLite files
in temporary directories instead of in-memory databases.
2. **Shared Database Connection**: Both MCP server and FastAPI app use the same
database via dependency injection overrides.
3. **Project Session Management**: Initializes the MCP project session with test
project configuration so tools know which project to operate on.
4. **Search Index Initialization**: Creates the FTS5 search index tables that
the application requires for search functionality.
5. **Global Configuration Override**: Modifies the global `basic_memory_app_config`
so MCP tools use test project settings instead of user configuration.
## Usage
Integration tests should include both `mcp_server` and `app` fixtures to ensure
the complete stack is wired correctly:
```python
@pytest.mark.asyncio
async def test_my_mcp_tool(mcp_server, app):
async with Client(mcp_server) as client:
result = await client.call_tool("tool_name", {"param": "value"})
# Assert on results...
```
The `app` fixture ensures FastAPI dependency overrides are active, and
`mcp_server` provides the MCP server with proper project session initialization.
"""
import os
from typing import AsyncGenerator
from unittest import mock
from unittest.mock import patch
import pytest
import pytest_asyncio
from pathlib import Path
from httpx import AsyncClient, ASGITransport
import basic_memory.config
import basic_memory.mcp.project_session
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager
from basic_memory.db import engine_session_factory, DatabaseType
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from fastapi import FastAPI
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
# Import MCP tools so they're available for testing
from basic_memory.mcp import tools # noqa: F401
@pytest_asyncio.fixture(scope="function")
async def engine_factory(tmp_path):
"""Create a SQLite file engine factory for integration testing."""
db_path = tmp_path / "test.db"
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
engine,
session_maker,
):
# Initialize database schema
from basic_memory.models.base import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine, session_maker
@pytest_asyncio.fixture(scope="function")
async def test_project(tmp_path, engine_factory) -> Project:
"""Create a test project."""
project_data = {
"name": "test-project",
"description": "Project used for integration tests",
"path": str(tmp_path),
"is_active": True,
"is_default": True,
}
engine, session_maker = engine_factory
project_repository = ProjectRepository(session_maker)
project = await project_repository.create(project_data)
return project
@pytest.fixture
def config_home(tmp_path, monkeypatch) -> Path:
monkeypatch.setenv("HOME", str(tmp_path))
return tmp_path
@pytest.fixture(scope="function")
def app_config(config_home, test_project, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
projects = {test_project.name: str(test_project.path)}
app_config = BasicMemoryConfig(env="test", projects=projects, default_project=test_project.name, update_permalinks_on_move=True)
# Set the module app_config instance project list (like regular tests)
monkeypatch.setattr("basic_memory.config.app_config", app_config)
return app_config
@pytest.fixture
def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> ConfigManager:
config_manager = ConfigManager()
# Update its paths to use the test directory
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Override the config directly instead of relying on disk load
config_manager.config = app_config
# Ensure the config file is written to disk
config_manager.save_config(app_config)
# Patch the config_manager in all locations where it's imported
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
return config_manager
@pytest.fixture
def project_session(test_project: Project):
# initialize the project session with the test project
basic_memory.mcp.project_session.session.initialize(test_project.name)
@pytest.fixture(scope="function")
def project_config(test_project, monkeypatch):
"""Create test project configuration."""
project_config = ProjectConfig(
name=test_project.name,
home=Path(test_project.path),
)
# override config module project config
monkeypatch.setattr("basic_memory.config.config", project_config)
return project_config
@pytest.fixture(scope="function")
def app(app_config, project_config, engine_factory, test_project, project_session, config_manager) -> FastAPI:
"""Create test FastAPI application with single project."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: project_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
app.dependency_overrides[get_app_config] = lambda: app_config
return app
@pytest_asyncio.fixture(scope="function")
async def search_service(engine_factory, test_project):
"""Create and initialize search service for integration tests."""
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.services.file_service import FileService
from basic_memory.services.search_service import SearchService
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown import EntityParser
engine, session_maker = engine_factory
# Create repositories
search_repository = SearchRepository(session_maker, project_id=test_project.id)
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
# Create file service
entity_parser = EntityParser(Path(test_project.path))
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(Path(test_project.path), markdown_processor)
# Create and initialize search service
service = SearchService(search_repository, entity_repository, file_service)
await service.init_search_index()
return service
@pytest.fixture(scope="function")
def mcp_server(app_config, search_service):
# Import mcp instance
from basic_memory.mcp.server import mcp as server
# Import mcp tools to register them
import basic_memory.mcp.tools # noqa: F401
# Import prompts to register them
import basic_memory.mcp.prompts # noqa: F401
# Initialize project session with test project
from basic_memory.mcp.project_session import session
session.initialize(app_config.default_project)
return server
@pytest_asyncio.fixture(scope="function")
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
@@ -0,0 +1,422 @@
"""
Integration tests for delete_note MCP tool.
Tests the complete delete note workflow: MCP client -> MCP server -> FastAPI -> database
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_delete_note_by_title(mcp_server, app):
"""Test deleting a note by its title."""
async with Client(mcp_server) as client:
# First create a note
await client.call_tool(
"write_note",
{
"title": "Note to Delete",
"folder": "test",
"content": "# Note to Delete\n\nThis note will be deleted.",
"tags": "test,delete",
},
)
# Verify the note exists by reading it
read_result = await client.call_tool(
"read_note",
{
"identifier": "Note to Delete",
},
)
assert len(read_result) == 1
assert "Note to Delete" in read_result[0].text
# Delete the note by title
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "Note to Delete",
},
)
# Should return True for successful deletion
assert len(delete_result) == 1
assert delete_result[0].type == "text"
assert "true" in delete_result[0].text.lower()
# Verify the note no longer exists
read_after_delete = await client.call_tool(
"read_note",
{
"identifier": "Note to Delete",
},
)
# Should return helpful "Note Not Found" message instead of the actual note
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert "Note to Delete" in result_text
assert "I couldn't find any notes matching" in result_text
@pytest.mark.asyncio
async def test_delete_note_by_permalink(mcp_server, app):
"""Test deleting a note by its permalink."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Permalink Delete Test",
"folder": "tests",
"content": "# Permalink Delete Test\n\nTesting deletion by permalink.",
"tags": "test,permalink",
},
)
# Delete the note by permalink
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "tests/permalink-delete-test",
},
)
# Should return True for successful deletion
assert len(delete_result) == 1
assert "true" in delete_result[0].text.lower()
# Verify the note no longer exists by searching
search_result = await client.call_tool(
"search_notes",
{
"query": "Permalink Delete Test",
},
)
# Should have no results
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
@pytest.mark.asyncio
async def test_delete_note_with_observations_and_relations(mcp_server, app):
"""Test deleting a note that has observations and relations."""
async with Client(mcp_server) as client:
# Create a complex note with observations and relations
complex_content = """# Project Management System
This is a comprehensive project management system.
## Observations
- [feature] Task tracking functionality
- [feature] User authentication system
- [tech] Built with Python and Flask
- [status] Currently in development
## Relations
- depends_on [[Database Schema]]
- implements [[User Stories]]
- part_of [[Main Application]]
The system handles multiple projects and users."""
await client.call_tool(
"write_note",
{
"title": "Project Management System",
"folder": "projects",
"content": complex_content,
"tags": "project,management,system",
},
)
# Verify the note exists and has content
read_result = await client.call_tool(
"read_note",
{
"identifier": "Project Management System",
},
)
assert len(read_result) == 1
result_text = read_result[0].text
assert "Task tracking functionality" in result_text
assert "depends_on" in result_text
# Delete the complex note
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "projects/project-management-system",
},
)
# Should return True for successful deletion
assert "true" in delete_result[0].text.lower()
# Verify the note and all its components are deleted
read_after_delete_2 = await client.call_tool(
"read_note",
{
"identifier": "Project Management System",
},
)
# Should return "Note Not Found" message
assert len(read_after_delete_2) == 1
result_text = read_after_delete_2[0].text
assert "Note Not Found" in result_text
assert "Project Management System" in result_text
@pytest.mark.asyncio
async def test_delete_note_special_characters_in_title(mcp_server, app):
"""Test deleting notes with special characters in the title."""
async with Client(mcp_server) as client:
# Create notes with special characters
special_titles = [
"Note with spaces",
"Note-with-dashes",
"Note_with_underscores",
"Note (with parentheses)",
"Note & Symbols!",
]
# Create all the notes
for title in special_titles:
await client.call_tool(
"write_note",
{
"title": title,
"folder": "special",
"content": f"# {title}\n\nContent for {title}",
"tags": "special,characters",
},
)
# Delete each note by title
for title in special_titles:
delete_result = await client.call_tool(
"delete_note",
{
"identifier": title,
},
)
# Should return True for successful deletion
assert "true" in delete_result[0].text.lower(), f"Failed to delete note: {title}"
# Verify the note is deleted
read_after_delete = await client.call_tool(
"read_note",
{
"identifier": title,
},
)
# Should return "Note Not Found" message
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert title in result_text
@pytest.mark.asyncio
async def test_delete_nonexistent_note(mcp_server, app):
"""Test attempting to delete a note that doesn't exist."""
async with Client(mcp_server) as client:
# Try to delete a note that doesn't exist
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "Nonexistent Note",
},
)
# Should return False for unsuccessful deletion
assert len(delete_result) == 1
assert "false" in delete_result[0].text.lower()
@pytest.mark.asyncio
async def test_delete_note_by_file_path(mcp_server, app):
"""Test deleting a note using its file path."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "File Path Delete",
"folder": "docs",
"content": "# File Path Delete\n\nTesting deletion by file path.",
"tags": "test,filepath",
},
)
# Try to delete using the file path (should work as an identifier)
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "docs/File Path Delete.md",
},
)
# Should return True for successful deletion
assert "true" in delete_result[0].text.lower()
# Verify deletion
read_after_delete = await client.call_tool(
"read_note",
{
"identifier": "File Path Delete",
},
)
# Should return "Note Not Found" message
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert "File Path Delete" in result_text
@pytest.mark.asyncio
async def test_delete_note_case_insensitive(mcp_server, app):
"""Test that note deletion is case insensitive for titles."""
async with Client(mcp_server) as client:
# Create a note with mixed case
await client.call_tool(
"write_note",
{
"title": "CamelCase Note Title",
"folder": "test",
"content": "# CamelCase Note Title\n\nTesting case sensitivity.",
"tags": "test,case",
},
)
# Try to delete with different case
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "camelcase note title",
},
)
# Should return True for successful deletion
assert "true" in delete_result[0].text.lower()
@pytest.mark.asyncio
async def test_delete_multiple_notes_sequentially(mcp_server, app):
"""Test deleting multiple notes in sequence."""
async with Client(mcp_server) as client:
# Create multiple notes
note_titles = [
"First Note",
"Second Note",
"Third Note",
"Fourth Note",
"Fifth Note",
]
for title in note_titles:
await client.call_tool(
"write_note",
{
"title": title,
"folder": "batch",
"content": f"# {title}\n\nContent for {title}",
"tags": "batch,test",
},
)
# Delete all notes sequentially
for title in note_titles:
delete_result = await client.call_tool(
"delete_note",
{
"identifier": title,
},
)
# Each deletion should be successful
assert "true" in delete_result[0].text.lower(), f"Failed to delete {title}"
# Verify all notes are deleted by searching
search_result = await client.call_tool(
"search_notes",
{
"query": "batch",
},
)
# Should have no results
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
@pytest.mark.asyncio
async def test_delete_note_with_unicode_content(mcp_server, app):
"""Test deleting notes with Unicode content."""
async with Client(mcp_server) as client:
# Create a note with Unicode content
unicode_content = """# Unicode Test Note 🚀
This note contains various Unicode characters:
- Emojis: 🎉 🔥 ⚡ 💡
- Languages: 测试中文 Tëst Übër
- Symbols: ♠♣♥♦ ←→↑↓ ∞≠≤≥
- Math: ∑∏∂∇∆Ω
## Observations
- [test] Unicode characters preserved ✓
- [note] Emoji support working 🎯
## Relations
- supports [[Unicode Standards]]
- tested_with [[Various Languages]]"""
await client.call_tool(
"write_note",
{
"title": "Unicode Test Note",
"folder": "unicode",
"content": unicode_content,
"tags": "unicode,test,emoji",
},
)
# Delete the Unicode note
delete_result = await client.call_tool(
"delete_note",
{
"identifier": "Unicode Test Note",
},
)
# Should return True for successful deletion
assert "true" in delete_result[0].text.lower()
# Verify deletion
read_after_delete = await client.call_tool(
"read_note",
{
"identifier": "Unicode Test Note",
},
)
# Should return "Note Not Found" message
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert "Unicode Test Note" in result_text
+608
View File
@@ -0,0 +1,608 @@
"""
Integration tests for edit_note MCP tool.
Tests the complete edit note workflow: MCP client -> MCP server -> FastAPI -> database
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_edit_note_append_operation(mcp_server, app):
"""Test appending content to an existing note."""
async with Client(mcp_server) as client:
# First create a note
await client.call_tool(
"write_note",
{
"title": "Append Test Note",
"folder": "test",
"content": "# Append Test Note\n\nOriginal content here.",
"tags": "test,append",
},
)
# Test appending content
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Append Test Note",
"operation": "append",
"content": "\n\n## New Section\n\nThis content was appended.",
},
)
# Should return successful edit summary
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (append)" in edit_text
assert "Added 5 lines to end of note" in edit_text
assert "test/append-test-note" in edit_text
# Verify the content was actually appended
read_result = await client.call_tool(
"read_note",
{
"identifier": "Append Test Note",
},
)
content = read_result[0].text
assert "Original content here." in content
assert "## New Section" in content
assert "This content was appended." in content
@pytest.mark.asyncio
async def test_edit_note_prepend_operation(mcp_server, app):
"""Test prepending content to an existing note."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Prepend Test Note",
"folder": "test",
"content": "# Prepend Test Note\n\nExisting content.",
"tags": "test,prepend",
},
)
# Test prepending content
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "test/prepend-test-note",
"operation": "prepend",
"content": "## Important Update\n\nThis was added at the top.\n\n",
},
)
# Should return successful edit summary
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (prepend)" in edit_text
assert "Added 5 lines to beginning of note" in edit_text
# Verify the content was prepended after frontmatter
read_result = await client.call_tool(
"read_note",
{
"identifier": "test/prepend-test-note",
},
)
content = read_result[0].text
assert "## Important Update" in content
assert "This was added at the top." in content
assert "Existing content." in content
# Check that prepended content comes before existing content
prepend_pos = content.find("Important Update")
existing_pos = content.find("Existing content")
assert prepend_pos < existing_pos
@pytest.mark.asyncio
async def test_edit_note_find_replace_operation(mcp_server, app):
"""Test find and replace operation on an existing note."""
async with Client(mcp_server) as client:
# Create a note with content to replace
await client.call_tool(
"write_note",
{
"title": "Find Replace Test",
"folder": "test",
"content": """# Find Replace Test
This is version v1.0.0 of the system.
## Notes
- The current version is v1.0.0
- Next version will be v1.1.0
## Changes
v1.0.0 introduces new features.""",
"tags": "test,version",
},
)
# Test find and replace operation (expecting 3 replacements)
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Find Replace Test",
"operation": "find_replace",
"content": "v1.2.0",
"find_text": "v1.0.0",
"expected_replacements": 3,
},
)
# Should return successful edit summary
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (find_replace)" in edit_text
assert "Find and replace operation completed" in edit_text
# Verify the replacements were made
read_result = await client.call_tool(
"read_note",
{
"identifier": "Find Replace Test",
},
)
content = read_result[0].text
assert "v1.2.0" in content
assert "v1.0.0" not in content # Should be completely replaced
assert content.count("v1.2.0") == 3 # Should have exactly 3 occurrences
@pytest.mark.asyncio
async def test_edit_note_replace_section_operation(mcp_server, app):
"""Test replacing content under a specific section header."""
async with Client(mcp_server) as client:
# Create a note with sections
await client.call_tool(
"write_note",
{
"title": "Section Replace Test",
"folder": "test",
"content": """# Section Replace Test
## Overview
Original overview content.
## Implementation
Old implementation details here.
This will be replaced.
## Future Work
Some future work notes.""",
"tags": "test,section",
},
)
# Test replacing section content
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "test/section-replace-test",
"operation": "replace_section",
"content": """New implementation approach using microservices.
- Service A handles authentication
- Service B manages data processing
- Service C provides API endpoints
All services communicate via message queues.""",
"section": "## Implementation",
},
)
# Should return successful edit summary
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (replace_section)" in edit_text
assert "Replaced content under section '## Implementation'" in edit_text
# Verify the section was replaced
read_result = await client.call_tool(
"read_note",
{
"identifier": "Section Replace Test",
},
)
content = read_result[0].text
assert "New implementation approach using microservices" in content
assert "Old implementation details here" not in content
assert "Service A handles authentication" in content
# Other sections should remain unchanged
assert "Original overview content" in content
assert "Some future work notes" in content
@pytest.mark.asyncio
async def test_edit_note_with_observations_and_relations(mcp_server, app):
"""Test editing a note that has observations and relations, and verify they're updated."""
async with Client(mcp_server) as client:
# Create a complex note with observations and relations
complex_content = """# API Documentation
The API provides REST endpoints for data access.
## Observations
- [feature] User authentication endpoints
- [tech] Built with FastAPI framework
- [status] Currently in beta testing
## Relations
- implements [[Authentication System]]
- documented_in [[API Guide]]
- depends_on [[Database Schema]]
## Endpoints
Current endpoints include user management."""
await client.call_tool(
"write_note",
{
"title": "API Documentation",
"folder": "docs",
"content": complex_content,
"tags": "api,docs",
},
)
# Add new content with observations and relations
new_content = """
## New Features
- [feature] Added payment processing endpoints
- [feature] Implemented rate limiting
- [security] Added OAuth2 authentication
## Additional Relations
- integrates_with [[Payment Gateway]]
- secured_by [[OAuth2 Provider]]"""
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "API Documentation",
"operation": "append",
"content": new_content,
},
)
# Should return edit summary with observation and relation counts
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (append)" in edit_text
assert "## Observations" in edit_text
assert "## Relations" in edit_text
# Should have feature, tech, status, security categories
assert "feature:" in edit_text
assert "security:" in edit_text
assert "tech:" in edit_text
assert "status:" in edit_text
# Verify the content was added and processed
read_result = await client.call_tool(
"read_note",
{
"identifier": "API Documentation",
},
)
content = read_result[0].text
assert "Added payment processing endpoints" in content
assert "integrates_with [[Payment Gateway]]" in content
@pytest.mark.asyncio
async def test_edit_note_error_handling_note_not_found(mcp_server, app):
"""Test error handling when trying to edit a non-existent note."""
async with Client(mcp_server) as client:
# Try to edit a note that doesn't exist
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Non-existent Note",
"operation": "append",
"content": "Some content to add",
},
)
# Should return helpful error message
assert len(edit_result) == 1
error_text = edit_result[0].text
assert "Edit Failed - Note Not Found" in error_text
assert "Non-existent Note" in error_text
assert "search_notes(" in error_text
assert "Suggestions to try:" in error_text
@pytest.mark.asyncio
async def test_edit_note_error_handling_text_not_found(mcp_server, app):
"""Test error handling when find_text is not found in the note."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Error Test Note",
"folder": "test",
"content": "# Error Test Note\n\nThis note has specific content.",
"tags": "test,error",
},
)
# Try to replace text that doesn't exist
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Error Test Note",
"operation": "find_replace",
"content": "replacement text",
"find_text": "non-existent text",
},
)
# Should return helpful error message
assert len(edit_result) == 1
error_text = edit_result[0].text
assert "Edit Failed - Text Not Found" in error_text
assert "non-existent text" in error_text
assert "Error Test Note" in error_text
assert "read_note(" in error_text
@pytest.mark.asyncio
async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app):
"""Test error handling when expected_replacements doesn't match actual occurrences."""
async with Client(mcp_server) as client:
# Create a note with specific repeated text
await client.call_tool(
"write_note",
{
"title": "Count Test Note",
"folder": "test",
"content": """# Count Test Note
The word "test" appears here.
This is another test sentence.
Final test of the content.""",
"tags": "test,count",
},
)
# Try to replace "test" but expect wrong count (should be 3, not 5)
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Count Test Note",
"operation": "find_replace",
"content": "example",
"find_text": "test",
"expected_replacements": 5,
},
)
# Should return helpful error message about count mismatch
assert len(edit_result) == 1
error_text = edit_result[0].text
assert "Edit Failed - Wrong Replacement Count" in error_text
assert "Expected 5 occurrences" in error_text
assert "test" in error_text
assert "expected_replacements=" in error_text
@pytest.mark.asyncio
async def test_edit_note_invalid_operation(mcp_server, app):
"""Test error handling for invalid operation parameter."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Invalid Op Test",
"folder": "test",
"content": "# Invalid Op Test\n\nSome content.",
"tags": "test",
},
)
# Try to use an invalid operation - this should raise a ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"edit_note",
{
"identifier": "Invalid Op Test",
"operation": "invalid_operation",
"content": "Some content",
},
)
# Should contain information about invalid operation
error_message = str(exc_info.value)
assert "Invalid operation 'invalid_operation'" in error_message
assert "append, prepend, find_replace, replace_section" in error_message
@pytest.mark.asyncio
async def test_edit_note_missing_required_parameters(mcp_server, app):
"""Test error handling when required parameters are missing."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Param Test Note",
"folder": "test",
"content": "# Param Test Note\n\nContent here.",
"tags": "test",
},
)
# Try find_replace without find_text parameter - this should raise a ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"edit_note",
{
"identifier": "Param Test Note",
"operation": "find_replace",
"content": "replacement",
# Missing find_text parameter
},
)
# Should contain information about missing parameter
error_message = str(exc_info.value)
assert "find_text parameter is required for find_replace operation" in error_message
@pytest.mark.asyncio
async def test_edit_note_special_characters_in_content(mcp_server, app):
"""Test editing notes with special characters, Unicode, and markdown formatting."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Special Chars Test",
"folder": "test",
"content": "# Special Chars Test\n\nBasic content here.",
"tags": "test,unicode",
},
)
# Add content with special characters and Unicode
special_content = """
## Unicode Section 🚀
This section contains:
- Emojis: 🎉 💡 ⚡ 🔥
- Languages: 测试中文 Tëst Übër
- Math symbols: ∑∏∂∇∆Ω ≠≤≥ ∞
- Special markdown: `code` **bold** *italic*
- URLs: https://example.com/path?param=value&other=123
- Code blocks:
```python
def test_function():
return "Hello, 世界!"
```
## Observations
- [unicode] Unicode characters preserved ✓
- [markdown] Formatting maintained 📝
## Relations
- documented_in [[Unicode Standards]]"""
edit_result = await client.call_tool(
"edit_note",
{
"identifier": "Special Chars Test",
"operation": "append",
"content": special_content,
},
)
# Should successfully handle special characters
assert len(edit_result) == 1
edit_text = edit_result[0].text
assert "Edited note (append)" in edit_text
assert "## Observations" in edit_text
assert "unicode:" in edit_text
assert "markdown:" in edit_text
# Verify the special content was added correctly
read_result = await client.call_tool(
"read_note",
{
"identifier": "Special Chars Test",
},
)
content = read_result[0].text
assert "🚀" in content
assert "测试中文" in content
assert "∑∏∂∇∆Ω" in content
assert "def test_function():" in content
assert "[[Unicode Standards]]" in content
@pytest.mark.asyncio
async def test_edit_note_using_different_identifiers(mcp_server, app):
"""Test editing notes using different identifier formats (title, permalink, folder/title)."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Identifier Test Note",
"folder": "docs",
"content": "# Identifier Test Note\n\nOriginal content.",
"tags": "test,identifier",
},
)
# Test editing by title
edit_result1 = await client.call_tool(
"edit_note",
{
"identifier": "Identifier Test Note", # by title
"operation": "append",
"content": "\n\nEdited by title.",
},
)
assert "Edited note (append)" in edit_result1[0].text
# Test editing by permalink
edit_result2 = await client.call_tool(
"edit_note",
{
"identifier": "docs/identifier-test-note", # by permalink
"operation": "append",
"content": "\n\nEdited by permalink.",
},
)
assert "Edited note (append)" in edit_result2[0].text
# Test editing by folder/title format
edit_result3 = await client.call_tool(
"edit_note",
{
"identifier": "docs/Identifier Test Note", # by folder/title
"operation": "append",
"content": "\n\nEdited by folder/title.",
},
)
assert "Edited note (append)" in edit_result3[0].text
# Verify all edits were applied
read_result = await client.call_tool(
"read_note",
{
"identifier": "docs/identifier-test-note",
},
)
content = read_result[0].text
assert "Edited by title." in content
assert "Edited by permalink." in content
assert "Edited by folder/title." in content
@@ -0,0 +1,467 @@
"""
Integration tests for list_directory MCP tool.
Tests the complete list directory workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_list_directory_basic_operation(mcp_server, app):
"""Test basic list_directory operation showing root contents."""
async with Client(mcp_server) as client:
# Create some test files and directories first
await client.call_tool(
"write_note",
{
"title": "Root Note",
"folder": "", # Root folder
"content": "# Root Note\n\nThis is in the root directory.",
"tags": "test,root",
},
)
await client.call_tool(
"write_note",
{
"title": "Project Planning",
"folder": "projects",
"content": "# Project Planning\n\nPlanning document for projects.",
"tags": "planning,project",
},
)
await client.call_tool(
"write_note",
{
"title": "Meeting Notes",
"folder": "meetings",
"content": "# Meeting Notes\n\nNotes from the meeting.",
"tags": "meeting,notes",
},
)
# List root directory
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/",
"depth": 1,
},
)
# Should return formatted directory listing
assert len(list_result) == 1
list_text = list_result[0].text
# Should show the structure
assert "Contents of '/' (depth 1):" in list_text
assert "📁 meetings" in list_text
assert "📁 projects" in list_text
assert "📄 Root Note.md" in list_text
assert "Root Note" in list_text # Title should be shown
assert "Total:" in list_text
assert "directories" in list_text
assert "file" in list_text
@pytest.mark.asyncio
async def test_list_directory_specific_folder(mcp_server, app):
"""Test listing contents of a specific folder."""
async with Client(mcp_server) as client:
# Create nested structure
await client.call_tool(
"write_note",
{
"title": "Task List",
"folder": "work",
"content": "# Task List\n\nWork tasks for today.",
"tags": "work,tasks",
},
)
await client.call_tool(
"write_note",
{
"title": "Project Alpha",
"folder": "work/projects",
"content": "# Project Alpha\n\nAlpha project documentation.",
"tags": "project,alpha",
},
)
await client.call_tool(
"write_note",
{
"title": "Daily Standup",
"folder": "work/meetings",
"content": "# Daily Standup\n\nStandup meeting notes.",
"tags": "meeting,standup",
},
)
# List specific folder
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/work",
"depth": 1,
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show work folder contents
assert "Contents of '/work' (depth 1):" in list_text
assert "📁 meetings" in list_text
assert "📁 projects" in list_text
assert "📄 Task List.md" in list_text
assert "work/Task List.md" in list_text # Path should be shown without leading slash
@pytest.mark.asyncio
async def test_list_directory_with_depth(mcp_server, app):
"""Test recursive directory listing with depth control."""
async with Client(mcp_server) as client:
# Create deep nested structure
await client.call_tool(
"write_note",
{
"title": "Deep Note",
"folder": "research/ml/algorithms/neural-networks",
"content": "# Deep Note\n\nDeep learning research.",
"tags": "research,ml,deep",
},
)
await client.call_tool(
"write_note",
{
"title": "ML Overview",
"folder": "research/ml",
"content": "# ML Overview\n\nMachine learning overview.",
"tags": "research,ml,overview",
},
)
await client.call_tool(
"write_note",
{
"title": "Research Index",
"folder": "research",
"content": "# Research Index\n\nIndex of research topics.",
"tags": "research,index",
},
)
# List with depth=3 to see nested structure
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/research",
"depth": 3,
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show nested structure within depth=3
assert "Contents of '/research' (depth 3):" in list_text
assert "📁 ml" in list_text
assert "📄 Research Index.md" in list_text
assert "📄 ML Overview.md" in list_text
assert "📁 algorithms" in list_text # Should show nested dirs within depth
@pytest.mark.asyncio
async def test_list_directory_with_glob_pattern(mcp_server, app):
"""Test directory listing with glob pattern filtering."""
async with Client(mcp_server) as client:
# Create files with different patterns
await client.call_tool(
"write_note",
{
"title": "Meeting 2025-01-15",
"folder": "meetings",
"content": "# Meeting 2025-01-15\n\nMonday meeting notes.",
"tags": "meeting,january",
},
)
await client.call_tool(
"write_note",
{
"title": "Meeting 2025-01-22",
"folder": "meetings",
"content": "# Meeting 2025-01-22\n\nMonday meeting notes.",
"tags": "meeting,january",
},
)
await client.call_tool(
"write_note",
{
"title": "Project Status",
"folder": "meetings",
"content": "# Project Status\n\nProject status update.",
"tags": "meeting,project",
},
)
# List with glob pattern for meeting files
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/meetings",
"depth": 1,
"file_name_glob": "Meeting*",
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show only matching files
assert "Files in '/meetings' matching 'Meeting*' (depth 1):" in list_text
assert "📄 Meeting 2025-01-15.md" in list_text
assert "📄 Meeting 2025-01-22.md" in list_text
assert "Project Status" not in list_text # Should be filtered out
@pytest.mark.asyncio
async def test_list_directory_empty_directory(mcp_server, app):
"""Test listing an empty directory."""
async with Client(mcp_server) as client:
# List non-existent/empty directory
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/empty",
"depth": 1,
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should indicate no files found
assert "No files found in directory '/empty'" in list_text
@pytest.mark.asyncio
async def test_list_directory_glob_no_matches(mcp_server, app):
"""Test glob pattern that matches no files."""
async with Client(mcp_server) as client:
# Create some files
await client.call_tool(
"write_note",
{
"title": "Document One",
"folder": "docs",
"content": "# Document One\n\nFirst document.",
"tags": "doc",
},
)
# List with glob pattern that won't match
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/docs",
"depth": 1,
"file_name_glob": "*.py", # No Python files
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should indicate no matches for the pattern
assert "No files found in directory '/docs' matching '*.py'" in list_text
@pytest.mark.asyncio
async def test_list_directory_various_file_types(mcp_server, app):
"""Test listing directories with various file types and metadata display."""
async with Client(mcp_server) as client:
# Create files with different characteristics
await client.call_tool(
"write_note",
{
"title": "Simple Note",
"folder": "mixed",
"content": "# Simple Note\n\nA simple note.",
"tags": "simple",
},
)
await client.call_tool(
"write_note",
{
"title": "Complex Document with Long Title",
"folder": "mixed",
"content": "# Complex Document with Long Title\n\nA more complex document.",
"tags": "complex,long",
},
)
# List the mixed directory
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/mixed",
"depth": 1,
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show file names, paths, and titles
assert "📄 Simple Note.md" in list_text
assert "mixed/Simple Note.md" in list_text
assert "📄 Complex Document with Long Title.md" in list_text
assert "mixed/Complex Document with Long Title.md" in list_text
assert "Total: 2 items (2 files)" in list_text
@pytest.mark.asyncio
async def test_list_directory_default_parameters(mcp_server, app):
"""Test list_directory with default parameters (root, depth=1)."""
async with Client(mcp_server) as client:
# Create some content
await client.call_tool(
"write_note",
{
"title": "Default Test",
"folder": "default-test",
"content": "# Default Test\n\nTesting default parameters.",
"tags": "default",
},
)
# List with minimal parameters (should use defaults)
list_result = await client.call_tool(
"list_directory",
{}, # Use all defaults
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show root directory with depth 1
assert "Contents of '/' (depth 1):" in list_text
assert "📁 default-test" in list_text
assert "Total:" in list_text
@pytest.mark.asyncio
async def test_list_directory_deep_recursion(mcp_server, app):
"""Test directory listing with maximum depth."""
async with Client(mcp_server) as client:
# Create very deep structure
await client.call_tool(
"write_note",
{
"title": "Level 5 Note",
"folder": "level1/level2/level3/level4/level5",
"content": "# Level 5 Note\n\nVery deep note.",
"tags": "deep,level5",
},
)
await client.call_tool(
"write_note",
{
"title": "Level 3 Note",
"folder": "level1/level2/level3",
"content": "# Level 3 Note\n\nMid-level note.",
"tags": "medium,level3",
},
)
# List with maximum depth (depth=10)
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/level1",
"depth": 10, # Maximum allowed depth
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show deep structure
assert "Contents of '/level1' (depth 10):" in list_text
assert "📁 level2" in list_text
assert "📄 Level 3 Note.md" in list_text
assert "📄 Level 5 Note.md" in list_text
@pytest.mark.asyncio
async def test_list_directory_complex_glob_patterns(mcp_server, app):
"""Test various glob patterns for file filtering."""
async with Client(mcp_server) as client:
# Create files with different naming patterns
await client.call_tool(
"write_note",
{
"title": "Project Alpha Plan",
"folder": "patterns",
"content": "# Project Alpha Plan\n\nAlpha planning.",
"tags": "project,alpha",
},
)
await client.call_tool(
"write_note",
{
"title": "Project Beta Plan",
"folder": "patterns",
"content": "# Project Beta Plan\n\nBeta planning.",
"tags": "project,beta",
},
)
await client.call_tool(
"write_note",
{
"title": "Meeting Minutes",
"folder": "patterns",
"content": "# Meeting Minutes\n\nMeeting notes.",
"tags": "meeting",
},
)
# Test wildcard pattern
list_result = await client.call_tool(
"list_directory",
{
"dir_name": "/patterns",
"file_name_glob": "Project*",
},
)
assert len(list_result) == 1
list_text = list_result[0].text
# Should show only Project files
assert "Project Alpha Plan.md" in list_text
assert "Project Beta Plan.md" in list_text
assert "Meeting Minutes" not in list_text
assert "matching 'Project*'" in list_text
+515
View File
@@ -0,0 +1,515 @@
"""
Integration tests for move_note MCP tool.
Tests the complete move note workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_move_note_basic_operation(mcp_server, app):
"""Test basic move note operation to a new folder."""
async with Client(mcp_server) as client:
# Create a note to move
await client.call_tool(
"write_note",
{
"title": "Move Test Note",
"folder": "source",
"content": "# Move Test Note\n\nThis note will be moved to a new location.",
"tags": "test,move",
},
)
# Move the note to a new location
move_result = await client.call_tool(
"move_note",
{
"identifier": "Move Test Note",
"destination_path": "destination/moved-note.md",
},
)
# Should return successful move message
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "Move Test Note" in move_text
assert "destination/moved-note.md" in move_text
assert "📊 Database and search index updated" in move_text
# Verify the note can be read from its new location
read_result = await client.call_tool(
"read_note",
{
"identifier": "destination/moved-note.md",
},
)
content = read_result[0].text
assert "This note will be moved to a new location" in content
# Verify the original location no longer works
read_original = await client.call_tool(
"read_note",
{
"identifier": "source/move-test-note.md",
},
)
# Should return "Note Not Found" message
assert "Note Not Found" in read_original[0].text
@pytest.mark.asyncio
async def test_move_note_using_permalink(mcp_server, app):
"""Test moving a note using its permalink as identifier."""
async with Client(mcp_server) as client:
# Create a note to move
await client.call_tool(
"write_note",
{
"title": "Permalink Move Test",
"folder": "test",
"content": "# Permalink Move Test\n\nMoving by permalink.",
"tags": "test,permalink",
},
)
# Move using permalink
move_result = await client.call_tool(
"move_note",
{
"identifier": "test/permalink-move-test",
"destination_path": "archive/permalink-moved.md",
},
)
# Should successfully move
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "test/permalink-move-test" in move_text
assert "archive/permalink-moved.md" in move_text
# Verify accessibility at new location
read_result = await client.call_tool(
"read_note",
{
"identifier": "archive/permalink-moved.md",
},
)
assert "Moving by permalink" in read_result[0].text
@pytest.mark.asyncio
async def test_move_note_with_observations_and_relations(mcp_server, app):
"""Test moving a note that contains observations and relations."""
async with Client(mcp_server) as client:
# Create complex note with observations and relations
complex_content = """# Complex Note
This note has various structured content.
## Observations
- [feature] Has structured observations
- [tech] Uses markdown format
- [status] Ready for move testing
## Relations
- implements [[Auth System]]
- documented_in [[Move Guide]]
- depends_on [[File System]]
## Content
This note demonstrates moving complex content."""
await client.call_tool(
"write_note",
{
"title": "Complex Note",
"folder": "complex",
"content": complex_content,
"tags": "test,complex,move",
},
)
# Move the complex note
move_result = await client.call_tool(
"move_note",
{
"identifier": "Complex Note",
"destination_path": "moved/complex-note.md",
},
)
# Should successfully move
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "Complex Note" in move_text
assert "moved/complex-note.md" in move_text
# Verify content preservation including structured data
read_result = await client.call_tool(
"read_note",
{
"identifier": "moved/complex-note.md",
},
)
content = read_result[0].text
assert "Has structured observations" in content
assert "implements [[Auth System]]" in content
assert "## Observations" in content
assert "[feature]" in content # Should show original markdown observations
assert "## Relations" in content
@pytest.mark.asyncio
async def test_move_note_to_nested_directory(mcp_server, app):
"""Test moving a note to a deeply nested directory structure."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Nested Move Test",
"folder": "root",
"content": "# Nested Move Test\n\nThis will be moved deep.",
"tags": "test,nested",
},
)
# Move to a deep nested structure
move_result = await client.call_tool(
"move_note",
{
"identifier": "Nested Move Test",
"destination_path": "projects/2025/q2/work/nested-note.md",
},
)
# Should successfully create directory structure and move
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "Nested Move Test" in move_text
assert "projects/2025/q2/work/nested-note.md" in move_text
# Verify accessibility
read_result = await client.call_tool(
"read_note",
{
"identifier": "projects/2025/q2/work/nested-note.md",
},
)
assert "This will be moved deep" in read_result[0].text
@pytest.mark.asyncio
async def test_move_note_with_special_characters(mcp_server, app):
"""Test moving notes with special characters in titles and paths."""
async with Client(mcp_server) as client:
# Create note with special characters
await client.call_tool(
"write_note",
{
"title": "Special (Chars) & Symbols",
"folder": "special",
"content": "# Special (Chars) & Symbols\n\nTesting special characters in move.",
"tags": "test,special",
},
)
# Move to path with special characters
move_result = await client.call_tool(
"move_note",
{
"identifier": "Special (Chars) & Symbols",
"destination_path": "archive/special-chars-note.md",
},
)
# Should handle special characters properly
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "archive/special-chars-note.md" in move_text
# Verify content preservation
read_result = await client.call_tool(
"read_note",
{
"identifier": "archive/special-chars-note.md",
},
)
assert "Testing special characters in move" in read_result[0].text
@pytest.mark.asyncio
async def test_move_note_error_handling_note_not_found(mcp_server, app):
"""Test error handling when trying to move a non-existent note."""
async with Client(mcp_server) as client:
# Try to move a note that doesn't exist - should raise ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"move_note",
{
"identifier": "Non-existent Note",
"destination_path": "new/location.md",
},
)
# Should contain error message about the failed operation
error_message = str(exc_info.value)
assert "move_note" in error_message and (
"Invalid request" in error_message or "Entity not found" in error_message
)
@pytest.mark.asyncio
async def test_move_note_error_handling_invalid_destination(mcp_server, app):
"""Test error handling for invalid destination paths."""
async with Client(mcp_server) as client:
# Create a note to attempt moving
await client.call_tool(
"write_note",
{
"title": "Invalid Dest Test",
"folder": "test",
"content": "# Invalid Dest Test\n\nThis move should fail.",
"tags": "test,error",
},
)
# Try to move to absolute path (should fail) - should raise ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"move_note",
{
"identifier": "Invalid Dest Test",
"destination_path": "/absolute/path/note.md",
},
)
# Should contain error message about the failed operation
error_message = str(exc_info.value)
assert "move_note" in error_message and (
"Invalid request" in error_message
or "Invalid destination path" in error_message
or "destination_path must be relative" in error_message
or "Client error (422)" in error_message
)
@pytest.mark.asyncio
async def test_move_note_error_handling_destination_exists(mcp_server, app):
"""Test error handling when destination file already exists."""
async with Client(mcp_server) as client:
# Create source note
await client.call_tool(
"write_note",
{
"title": "Source Note",
"folder": "source",
"content": "# Source Note\n\nThis is the source.",
"tags": "test,source",
},
)
# Create destination note that already exists at the exact path we'll try to move to
await client.call_tool(
"write_note",
{
"title": "Existing Note",
"folder": "destination",
"content": "# Existing Note\n\nThis already exists.",
"tags": "test,existing",
},
)
# Try to move source to existing destination (should fail) - should raise ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"move_note",
{
"identifier": "Source Note",
"destination_path": "destination/Existing Note.md", # Use exact existing file name
},
)
# Should contain error message about the failed operation
error_message = str(exc_info.value)
assert "move_note" in error_message and (
"Destination already exists: destination/Existing Note.md" in error_message
)
@pytest.mark.asyncio
async def test_move_note_preserves_search_functionality(mcp_server, app):
"""Test that moved notes remain searchable after move operation."""
async with Client(mcp_server) as client:
# Create a note with searchable content
await client.call_tool(
"write_note",
{
"title": "Searchable Note",
"folder": "original",
"content": """# Searchable Note
This note contains unique search terms:
- quantum mechanics
- artificial intelligence
- machine learning algorithms
## Features
- [technology] Advanced AI features
- [research] Quantum computing research
## Relations
- relates_to [[AI Research]]""",
"tags": "search,test,move",
},
)
# Verify note is searchable before move
search_before = await client.call_tool(
"search_notes",
{
"query": "quantum mechanics",
},
)
assert len(search_before) > 0
assert "Searchable Note" in search_before[0].text
# Move the note
move_result = await client.call_tool(
"move_note",
{
"identifier": "Searchable Note",
"destination_path": "research/quantum-ai-note.md",
},
)
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
# Verify note is still searchable after move
search_after = await client.call_tool(
"search_notes",
{
"query": "quantum mechanics",
},
)
assert len(search_after) > 0
search_text = search_after[0].text
assert "quantum mechanics" in search_text
assert "research/quantum-ai-note.md" in search_text or "quantum-ai-note" in search_text
# Verify search by new location works
search_by_path = await client.call_tool(
"search_notes",
{
"query": "research/quantum",
},
)
assert len(search_by_path) > 0
@pytest.mark.asyncio
async def test_move_note_using_different_identifier_formats(mcp_server, app):
"""Test moving notes using different identifier formats (title, permalink, folder/title)."""
async with Client(mcp_server) as client:
# Create notes for different identifier tests
await client.call_tool(
"write_note",
{
"title": "Title ID Note",
"folder": "test",
"content": "# Title ID Note\n\nMove by title.",
"tags": "test,identifier",
},
)
await client.call_tool(
"write_note",
{
"title": "Permalink ID Note",
"folder": "test",
"content": "# Permalink ID Note\n\nMove by permalink.",
"tags": "test,identifier",
},
)
await client.call_tool(
"write_note",
{
"title": "Folder Title Note",
"folder": "test",
"content": "# Folder Title Note\n\nMove by folder/title.",
"tags": "test,identifier",
},
)
# Test moving by title
move1 = await client.call_tool(
"move_note",
{
"identifier": "Title ID Note", # by title
"destination_path": "moved/title-moved.md",
},
)
assert len(move1) == 1
assert "✅ Note moved successfully" in move1[0].text
# Test moving by permalink
move2 = await client.call_tool(
"move_note",
{
"identifier": "test/permalink-id-note", # by permalink
"destination_path": "moved/permalink-moved.md",
},
)
assert len(move2) == 1
assert "✅ Note moved successfully" in move2[0].text
# Test moving by folder/title format
move3 = await client.call_tool(
"move_note",
{
"identifier": "test/Folder Title Note", # by folder/title
"destination_path": "moved/folder-title-moved.md",
},
)
assert len(move3) == 1
assert "✅ Note moved successfully" in move3[0].text
# Verify all notes can be accessed at their new locations
read1 = await client.call_tool("read_note", {"identifier": "moved/title-moved.md"})
assert "Move by title" in read1[0].text
read2 = await client.call_tool("read_note", {"identifier": "moved/permalink-moved.md"})
assert "Move by permalink" in read2[0].text
read3 = await client.call_tool("read_note", {"identifier": "moved/folder-title-moved.md"})
assert "Move by folder/title" in read3[0].text
@@ -0,0 +1,637 @@
"""
Integration tests for project_management MCP tools.
Tests the complete project management workflow: MCP client -> MCP server -> FastAPI -> project service
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_list_projects_basic_operation(mcp_server, app):
"""Test basic list_projects operation showing available projects."""
async with Client(mcp_server) as client:
# List all available projects
list_result = await client.call_tool(
"list_projects",
{},
)
# Should return formatted project list
assert len(list_result) == 1
list_text = list_result[0].text
# Should show available projects with status indicators
assert "Available projects:" in list_text
assert "test-project" in list_text # Our default test project
assert "(current, default)" in list_text or "(default)" in list_text
assert "Project: test-project" in list_text # Project metadata
@pytest.mark.asyncio
async def test_get_current_project_operation(mcp_server, app):
"""Test get_current_project showing current project info."""
async with Client(mcp_server) as client:
# Create some test content first to have stats
await client.call_tool(
"write_note",
{
"title": "Test Note",
"folder": "test",
"content": "# Test Note\n\nTest content.\n\n- [feature] Test observation",
"tags": "test",
},
)
# Get current project info
current_result = await client.call_tool(
"get_current_project",
{},
)
assert len(current_result) == 1
current_text = current_result[0].text
# Should show current project and stats
assert "Current project: test-project" in current_text
assert "entities" in current_text
assert "observations" in current_text
assert "relations" in current_text
assert "Project: test-project" in current_text # Project metadata
@pytest.mark.asyncio
async def test_project_info_with_entities(mcp_server, app):
"""Test that project info shows correct entity counts."""
async with Client(mcp_server) as client:
# Create multiple entities with observations and relations
await client.call_tool(
"write_note",
{
"title": "Entity One",
"folder": "stats",
"content": """# Entity One
This is the first entity.
## Observations
- [type] First entity type
- [status] Active entity
## Relations
- relates_to [[Entity Two]]
- implements [[Some System]]""",
"tags": "entity,test",
},
)
await client.call_tool(
"write_note",
{
"title": "Entity Two",
"folder": "stats",
"content": """# Entity Two
This is the second entity.
## Observations
- [type] Second entity type
- [priority] High priority
## Relations
- depends_on [[Entity One]]""",
"tags": "entity,test",
},
)
# Get current project info to see updated stats
current_result = await client.call_tool(
"get_current_project",
{},
)
assert len(current_result) == 1
current_text = current_result[0].text
# Should show entity and observation counts
assert "Current project: test-project" in current_text
# Should show at least the entities we created
assert (
"2 entities" in current_text or "3 entities" in current_text
) # May include other entities from setup
# Should show observations from our entities
assert (
"4 observations" in current_text
or "5 observations" in current_text
or "6 observations" in current_text
) # Our 4 + possibly more from setup
@pytest.mark.asyncio
async def test_switch_project_not_found(mcp_server, app):
"""Test switch_project with non-existent project shows error."""
async with Client(mcp_server) as client:
# Try to switch to non-existent project
switch_result = await client.call_tool(
"switch_project",
{
"project_name": "non-existent-project",
},
)
assert len(switch_result) == 1
switch_text = switch_result[0].text
# Should show error message with available projects
assert "Error: Project 'non-existent-project' not found" in switch_text
assert "Available projects:" in switch_text
assert "test-project" in switch_text
@pytest.mark.asyncio
async def test_switch_project_to_test_project(mcp_server, app):
"""Test switching to the currently active project."""
async with Client(mcp_server) as client:
# Switch to the same project (test-project)
switch_result = await client.call_tool(
"switch_project",
{
"project_name": "test-project",
},
)
assert len(switch_result) == 1
switch_text = switch_result[0].text
# Should show successful switch
assert "✓ Switched to test-project project" in switch_text
assert "Project Summary:" in switch_text
assert "entities" in switch_text
assert "observations" in switch_text
assert "relations" in switch_text
assert "Project: test-project" in switch_text # Project metadata
@pytest.mark.asyncio
async def test_set_default_project_operation(mcp_server, app):
"""Test set_default_project functionality."""
async with Client(mcp_server) as client:
# Get current project info (default)
current_result = await client.call_tool(
"get_current_project",
{},
)
assert len(current_result) == 1
current_text = current_result[0].text
# Should show current project and stats
assert "Current project: test-project" in current_text
# Set test-project as default (it likely already is, but test the operation)
default_result = await client.call_tool(
"set_default_project",
{
"project_name": "test-project",
},
)
assert len(default_result) == 1
default_text = default_result[0].text
# Should show success message and restart instructions
assert "" in default_text # Success indicator
assert "test-project" in default_text
assert "Restart Basic Memory for this change to take effect" in default_text
assert "basic-memory mcp" in default_text
assert "Project: test-project" in default_text # Project metadata
@pytest.mark.asyncio
async def test_set_default_project_not_found(mcp_server, app):
"""Test set_default_project with non-existent project."""
async with Client(mcp_server) as client:
# Try to set non-existent project as default
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"set_default_project",
{
"project_name": "non-existent-project",
},
)
# Should show error about non-existent project
error_message = str(exc_info.value)
assert "set_default_project" in error_message
assert (
"non-existent-project" in error_message
or "Invalid request" in error_message
or "Client error" in error_message
)
@pytest.mark.asyncio
async def test_project_management_workflow(mcp_server, app):
"""Test complete project management workflow."""
async with Client(mcp_server) as client:
# 1. Check current project
current_result = await client.call_tool("get_current_project", {})
assert "test-project" in current_result[0].text
# 2. List all projects
list_result = await client.call_tool("list_projects", {})
assert "Available projects:" in list_result[0].text
assert "test-project" in list_result[0].text
# 3. Switch to same project (should work)
switch_result = await client.call_tool("switch_project", {"project_name": "test-project"})
assert "✓ Switched to test-project project" in switch_result[0].text
# 4. Verify we're still on the same project
current_result2 = await client.call_tool("get_current_project", {})
assert "Current project: test-project" in current_result2[0].text
@pytest.mark.asyncio
async def test_project_metadata_consistency(mcp_server, app):
"""Test that all project management tools include consistent project metadata."""
async with Client(mcp_server) as client:
# Test all project management tools and verify they include project metadata
# list_projects
list_result = await client.call_tool("list_projects", {})
assert "Project: test-project" in list_result[0].text
# get_current_project
current_result = await client.call_tool("get_current_project", {})
assert "Project: test-project" in current_result[0].text
# switch_project
switch_result = await client.call_tool("switch_project", {"project_name": "test-project"})
assert "Project: test-project" in switch_result[0].text
# set_default_project (skip since API not working in test env)
# default_result = await client.call_tool(
# "set_default_project",
# {"project_name": "test-project"}
# )
# assert "Project: test-project" in default_result[0].text
@pytest.mark.asyncio
async def test_project_statistics_accuracy(mcp_server, app):
"""Test that project statistics reflect actual content."""
async with Client(mcp_server) as client:
# Get initial stats
initial_result = await client.call_tool("get_current_project", {})
initial_text = initial_result[0].text
assert initial_text is not None
# Create a new entity
await client.call_tool(
"write_note",
{
"title": "Stats Test Note",
"folder": "stats-test",
"content": """# Stats Test Note
Testing statistics accuracy.
## Observations
- [test] This is a test observation
- [accuracy] Testing stats accuracy
## Relations
- validates [[Project Statistics]]""",
"tags": "stats,test",
},
)
# Get updated stats
updated_result = await client.call_tool("get_current_project", {})
updated_text = updated_result[0].text
# Should show project info with stats
assert "Current project: test-project" in updated_text
assert "entities" in updated_text
assert "observations" in updated_text
assert "relations" in updated_text
# Stats should be reasonable (at least 1 entity, some observations)
import re
entity_match = re.search(r"(\d+) entities", updated_text)
obs_match = re.search(r"(\d+) observations", updated_text)
if entity_match:
entity_count = int(entity_match.group(1))
assert entity_count >= 1, f"Should have at least 1 entity, got {entity_count}"
if obs_match:
obs_count = int(obs_match.group(1))
assert obs_count >= 2, f"Should have at least 2 observations, got {obs_count}"
@pytest.mark.asyncio
async def test_create_project_basic_operation(mcp_server, app):
"""Test creating a new project with basic parameters."""
async with Client(mcp_server) as client:
# Create a new project
create_result = await client.call_tool(
"create_project",
{
"project_name": "test-new-project",
"project_path": "/tmp/test-new-project",
},
)
assert len(create_result) == 1
create_text = create_result[0].text
# Should show success message and project details
assert "" in create_text # Success indicator
assert "test-new-project" in create_text
assert "Project Details:" in create_text
assert "Name: test-new-project" in create_text
assert "Path: /tmp/test-new-project" in create_text
assert "Project is now available for use" in create_text
assert "Project: test-project" in create_text # Should still show current project
# Verify project appears in project list
list_result = await client.call_tool("list_projects", {})
list_text = list_result[0].text
assert "test-new-project" in list_text
@pytest.mark.asyncio
async def test_create_project_with_default_flag(mcp_server, app):
"""Test creating a project and setting it as default."""
async with Client(mcp_server) as client:
# Create a new project and set as default
create_result = await client.call_tool(
"create_project",
{
"project_name": "test-default-project",
"project_path": "/tmp/test-default-project",
"set_default": True,
},
)
assert len(create_result) == 1
create_text = create_result[0].text
# Should show success and default flag
assert "" in create_text
assert "test-default-project" in create_text
assert "Set as default project" in create_text
assert "Project: test-default-project" in create_text # Should switch to new project
# Verify we switched to the new project
current_result = await client.call_tool("get_current_project", {})
current_text = current_result[0].text
assert "Current project: test-default-project" in current_text
@pytest.mark.asyncio
async def test_create_project_duplicate_name(mcp_server, app):
"""Test creating a project with duplicate name shows error."""
async with Client(mcp_server) as client:
# First create a project
await client.call_tool(
"create_project",
{
"project_name": "duplicate-test",
"project_path": "/tmp/duplicate-test-1",
},
)
# Try to create another project with same name
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"create_project",
{
"project_name": "duplicate-test",
"project_path": "/tmp/duplicate-test-2",
},
)
# Should show error about duplicate name
error_message = str(exc_info.value)
assert "create_project" in error_message
assert (
"duplicate-test" in error_message
or "already exists" in error_message
or "Invalid request" in error_message
)
@pytest.mark.asyncio
async def test_delete_project_basic_operation(mcp_server, app):
"""Test deleting a project that exists."""
async with Client(mcp_server) as client:
# First create a project to delete
await client.call_tool(
"create_project",
{
"project_name": "to-be-deleted",
"project_path": "/tmp/to-be-deleted",
},
)
# Verify it exists
list_result = await client.call_tool("list_projects", {})
assert "to-be-deleted" in list_result[0].text
# Delete the project
delete_result = await client.call_tool(
"delete_project",
{
"project_name": "to-be-deleted",
},
)
assert len(delete_result) == 1
delete_text = delete_result[0].text
# Should show success message
assert "" in delete_text
assert "to-be-deleted" in delete_text
assert "removed successfully" in delete_text
assert "Removed project details:" in delete_text
assert "Name: to-be-deleted" in delete_text
assert "Files remain on disk but project is no longer tracked" in delete_text
assert "Project: test-project" in delete_text # Should show current project
# Verify project no longer appears in list
list_result_after = await client.call_tool("list_projects", {})
assert "to-be-deleted" not in list_result_after[0].text
@pytest.mark.asyncio
async def test_delete_project_not_found(mcp_server, app):
"""Test deleting a non-existent project shows error."""
async with Client(mcp_server) as client:
# Try to delete non-existent project
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"delete_project",
{
"project_name": "non-existent-project",
},
)
# Should show error about non-existent project
error_message = str(exc_info.value)
assert "delete_project" in error_message
assert (
"non-existent-project" in error_message
or "not found" in error_message
or "Invalid request" in error_message
)
@pytest.mark.asyncio
async def test_delete_current_project_protection(mcp_server, app):
"""Test that deleting the current project is prevented."""
async with Client(mcp_server) as client:
# Try to delete the current project (test-project)
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"delete_project",
{
"project_name": "test-project",
},
)
# Should show error about deleting current project
error_message = str(exc_info.value)
assert "delete_project" in error_message
assert (
"currently active" in error_message
or "test-project" in error_message
or "Switch to a different project" in error_message
)
@pytest.mark.asyncio
async def test_project_lifecycle_workflow(mcp_server, app):
"""Test complete project lifecycle: create, switch, use, delete."""
async with Client(mcp_server) as client:
project_name = "lifecycle-test"
project_path = "/tmp/lifecycle-test"
# 1. Create new project
create_result = await client.call_tool(
"create_project",
{
"project_name": project_name,
"project_path": project_path,
},
)
assert "" in create_result[0].text
assert project_name in create_result[0].text
# 2. Switch to the new project
switch_result = await client.call_tool(
"switch_project",
{
"project_name": project_name,
},
)
assert f"✓ Switched to {project_name} project" in switch_result[0].text
# 3. Create content in the new project
await client.call_tool(
"write_note",
{
"title": "Lifecycle Test Note",
"folder": "test",
"content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing",
"tags": "lifecycle,test",
},
)
# 4. Verify project stats show our content
current_result = await client.call_tool("get_current_project", {})
current_text = current_result[0].text
assert f"Current project: {project_name}" in current_text
assert "entities" in current_text
# 5. Switch back to original project
await client.call_tool(
"switch_project",
{
"project_name": "test-project",
},
)
# 6. Delete the lifecycle test project
delete_result = await client.call_tool(
"delete_project",
{
"project_name": project_name,
},
)
assert "" in delete_result[0].text
assert f"{project_name}" in delete_result[0].text
assert "removed successfully" in delete_result[0].text
# 7. Verify project is gone from list
list_result = await client.call_tool("list_projects", {})
assert project_name not in list_result[0].text
@pytest.mark.asyncio
async def test_create_delete_project_edge_cases(mcp_server, app):
"""Test edge cases for create and delete project operations."""
async with Client(mcp_server) as client:
# Test with special characters in project name (should be handled gracefully)
special_name = "test-project-with-dashes"
# Create project with special characters
create_result = await client.call_tool(
"create_project",
{
"project_name": special_name,
"project_path": f"/tmp/{special_name}",
},
)
assert "" in create_result[0].text
assert special_name in create_result[0].text
# Verify it appears in list
list_result = await client.call_tool("list_projects", {})
assert special_name in list_result[0].text
# Delete it
delete_result = await client.call_tool(
"delete_project",
{
"project_name": special_name,
},
)
assert "" in delete_result[0].text
assert special_name in delete_result[0].text
# Verify it's gone
list_result_after = await client.call_tool("list_projects", {})
assert special_name not in list_result_after[0].text
@@ -0,0 +1,367 @@
"""
Integration tests for read_content MCP tool.
Comprehensive tests covering text files, binary files, images, error cases,
and memory:// URL handling via the complete MCP client-server flow.
"""
import json
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
def parse_read_content_response(mcp_result):
"""Helper function to parse read_content MCP response."""
assert len(mcp_result) == 1
assert mcp_result[0].type == "text"
return json.loads(mcp_result[0].text)
@pytest.mark.asyncio
async def test_read_content_markdown_file(mcp_server, app):
"""Test reading a markdown file created by write_note."""
async with Client(mcp_server) as client:
# First create a note
await client.call_tool(
"write_note",
{
"title": "Content Test",
"folder": "test",
"content": "# Content Test\n\nThis is test content with **markdown**.",
"tags": "test,content",
},
)
# Then read the raw file content
read_result = await client.call_tool(
"read_content",
{
"path": "test/Content Test.md",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
assert response_data["type"] == "text"
assert response_data["content_type"] == "text/markdown; charset=utf-8"
assert response_data["encoding"] == "utf-8"
content = response_data["text"]
# Should contain the raw markdown with frontmatter
assert "# Content Test" in content
assert "This is test content with **markdown**." in content
assert "tags:" in content # frontmatter
assert "- test" in content # tags are in YAML list format
assert "- content" in content
@pytest.mark.asyncio
async def test_read_content_by_permalink(mcp_server, app):
"""Test reading content using permalink instead of file path."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Permalink Test",
"folder": "docs",
"content": "# Permalink Test\n\nTesting permalink-based content reading.",
},
)
# Read by permalink (without .md extension)
read_result = await client.call_tool(
"read_content",
{
"path": "docs/permalink-test",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
assert "# Permalink Test" in content
assert "Testing permalink-based content reading." in content
@pytest.mark.asyncio
async def test_read_content_memory_url(mcp_server, app):
"""Test reading content using memory:// URL format."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"title": "Memory URL Test",
"folder": "test",
"content": "# Memory URL Test\n\nTesting memory:// URL handling.",
"tags": "memory,url",
},
)
# Read using memory:// URL
read_result = await client.call_tool(
"read_content",
{
"path": "memory://test/memory-url-test",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
assert "# Memory URL Test" in content
assert "Testing memory:// URL handling." in content
@pytest.mark.asyncio
async def test_read_content_unicode_file(mcp_server, app):
"""Test reading content with unicode characters and emojis."""
async with Client(mcp_server) as client:
# Create a note with unicode content
unicode_content = (
"# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦\n\n测试中文内容"
)
await client.call_tool(
"write_note",
{
"title": "Unicode Content Test",
"folder": "test",
"content": unicode_content,
"tags": "unicode,emoji",
},
)
# Read the content back
read_result = await client.call_tool(
"read_content",
{
"path": "test/Unicode Content Test.md",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
# All unicode content should be preserved
assert "🚀" in content
assert "🎉" in content
assert "♠♣♥♦" in content
assert "测试中文内容" in content
@pytest.mark.asyncio
async def test_read_content_complex_frontmatter(mcp_server, app):
"""Test reading content with complex frontmatter and markdown."""
async with Client(mcp_server) as client:
# Create a note with complex content
complex_content = """---
title: Complex Note
type: document
version: 1.0
author: Test Author
metadata:
status: draft
priority: high
---
# Complex Note
This note has complex frontmatter and various markdown elements.
## Observations
- [tech] Uses YAML frontmatter
- [design] Structured content format
## Relations
- related_to [[Other Note]]
- depends_on [[Framework]]
Regular markdown content continues here."""
await client.call_tool(
"write_note",
{
"title": "Complex Note",
"folder": "docs",
"content": complex_content,
"tags": "complex,frontmatter",
},
)
# Read the content back
read_result = await client.call_tool(
"read_content",
{
"path": "docs/Complex Note.md",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
# Should preserve all frontmatter and content structure
assert "version: 1.0" in content
assert "author: Test Author" in content
assert "status: draft" in content
assert "[tech] Uses YAML frontmatter" in content
assert "[[Other Note]]" in content
@pytest.mark.asyncio
async def test_read_content_missing_file(mcp_server, app):
"""Test reading a file that doesn't exist."""
async with Client(mcp_server) as client:
try:
await client.call_tool(
"read_content",
{
"path": "nonexistent/file.md",
},
)
# Should not reach here - expecting an error
assert False, "Expected error for missing file"
except ToolError as e:
# Should get an appropriate error message
error_msg = str(e).lower()
assert "not found" in error_msg or "does not exist" in error_msg
@pytest.mark.asyncio
async def test_read_content_empty_file(mcp_server, app):
"""Test reading an empty file."""
async with Client(mcp_server) as client:
# Create a note with minimal content
await client.call_tool(
"write_note",
{
"title": "Empty Test",
"folder": "test",
"content": "", # Empty content
},
)
# Read the content back
read_result = await client.call_tool(
"read_content",
{
"path": "test/Empty Test.md",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
# Should still have frontmatter even with empty content
assert "title: Empty Test" in content
assert "permalink: test/empty-test" in content
@pytest.mark.asyncio
async def test_read_content_large_file(mcp_server, app):
"""Test reading a file with substantial content."""
async with Client(mcp_server) as client:
# Create a note with substantial content
large_content = "# Large Content Test\n\n"
# Add multiple sections with substantial text
for i in range(10):
large_content += f"""
## Section {i + 1}
This is section {i + 1} with substantial content. Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
- [note] This is observation {i + 1}
- related_to [[Section {i}]]
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident.
"""
await client.call_tool(
"write_note",
{
"title": "Large Content Note",
"folder": "test",
"content": large_content,
"tags": "large,content,test",
},
)
# Read the content back
read_result = await client.call_tool(
"read_content",
{
"path": "test/Large Content Note.md",
},
)
# Parse the response
response_data = parse_read_content_response(read_result)
content = response_data["text"]
# Should contain all sections
assert "Section 1" in content
assert "Section 10" in content
assert "Lorem ipsum" in content
assert len(content) > 1000 # Should be substantial
@pytest.mark.asyncio
async def test_read_content_special_characters_in_filename(mcp_server, app):
"""Test reading files with special characters in the filename."""
async with Client(mcp_server) as client:
# Create notes with special characters in titles
test_cases = [
("File with spaces", "test"),
("File-with-dashes", "test"),
("File_with_underscores", "test"),
("File (with parentheses)", "test"),
("File & Symbols!", "test"),
]
for title, folder in test_cases:
await client.call_tool(
"write_note",
{
"title": title,
"folder": folder,
"content": f"# {title}\n\nContent for {title}",
},
)
# Read the content back using the exact filename
read_result = await client.call_tool(
"read_content",
{
"path": f"{folder}/{title}.md",
},
)
assert len(read_result) == 1
assert read_result[0].type == "text"
content = read_result[0].text
assert f"# {title}" in content
assert f"Content for {title}" in content
@@ -0,0 +1,46 @@
"""
Integration tests for read_note MCP tool.
Tests the full flow: MCP client -> MCP server -> FastAPI -> database
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_read_note_after_write(mcp_server, app):
"""Test read_note after write_note using real database."""
async with Client(mcp_server) as client:
# First write a note
write_result = await client.call_tool(
"write_note",
{
"title": "Test Note",
"folder": "test",
"content": "# Test Note\n\nThis is test content.",
"tags": "test,integration",
},
)
assert len(write_result) == 1
assert write_result[0].type == "text"
assert "Test Note.md" in write_result[0].text
# Then read it back
read_result = await client.call_tool(
"read_note",
{
"identifier": "Test Note",
},
)
assert len(read_result) == 1
assert read_result[0].type == "text"
result_text = read_result[0].text
# Should contain the note content and metadata
assert "# Test Note" in result_text
assert "This is test content." in result_text
assert "test/test-note" in result_text # permalink
+468
View File
@@ -0,0 +1,468 @@
"""
Integration tests for search_notes MCP tool.
Comprehensive tests covering search functionality using the complete
MCP client-server flow with real databases.
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_search_basic_text_search(mcp_server, app):
"""Test basic text search functionality."""
async with Client(mcp_server) as client:
# Create test notes for searching
await client.call_tool(
"write_note",
{
"title": "Python Programming Guide",
"folder": "docs",
"content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.",
"tags": "python,programming",
},
)
await client.call_tool(
"write_note",
{
"title": "Flask Web Development",
"folder": "docs",
"content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.",
"tags": "python,flask,web",
},
)
await client.call_tool(
"write_note",
{
"title": "JavaScript Basics",
"folder": "docs",
"content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.",
"tags": "javascript,programming",
},
)
# Search for Python-related content
search_result = await client.call_tool(
"search_notes",
{
"query": "Python",
},
)
assert len(search_result) == 1
assert search_result[0].type == "text"
# Parse the response (it should be a SearchResponse)
result_text = search_result[0].text
assert "Python Programming Guide" in result_text
assert "Flask Web Development" in result_text
assert "JavaScript Basics" not in result_text
@pytest.mark.asyncio
async def test_search_boolean_operators(mcp_server, app):
"""Test boolean search operators (AND, OR, NOT)."""
async with Client(mcp_server) as client:
# Create test notes
await client.call_tool(
"write_note",
{
"title": "Python Flask Tutorial",
"folder": "tutorials",
"content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.",
"tags": "python,flask,tutorial",
},
)
await client.call_tool(
"write_note",
{
"title": "Python Django Guide",
"folder": "tutorials",
"content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.",
"tags": "python,django,web",
},
)
await client.call_tool(
"write_note",
{
"title": "React JavaScript",
"folder": "tutorials",
"content": "# React JavaScript\n\nBuilding frontend applications with React.",
"tags": "javascript,react,frontend",
},
)
# Test AND operator
search_result = await client.call_tool(
"search_notes",
{
"query": "Python AND Flask",
},
)
result_text = search_result[0].text
assert "Python Flask Tutorial" in result_text
assert "Python Django Guide" not in result_text
assert "React JavaScript" not in result_text
# Test OR operator
search_result = await client.call_tool(
"search_notes",
{
"query": "Flask OR Django",
},
)
result_text = search_result[0].text
assert "Python Flask Tutorial" in result_text
assert "Python Django Guide" in result_text
assert "React JavaScript" not in result_text
# Test NOT operator
search_result = await client.call_tool(
"search_notes",
{
"query": "Python NOT Django",
},
)
result_text = search_result[0].text
assert "Python Flask Tutorial" in result_text
assert "Python Django Guide" not in result_text
@pytest.mark.asyncio
async def test_search_title_only(mcp_server, app):
"""Test searching in titles only."""
async with Client(mcp_server) as client:
# Create test notes
await client.call_tool(
"write_note",
{
"title": "Database Design",
"folder": "docs",
"content": "# Database Design\n\nThis covers SQL and database concepts.",
"tags": "database,sql",
},
)
await client.call_tool(
"write_note",
{
"title": "Web Development",
"folder": "docs",
"content": "# Web Development\n\nDatabase integration in web applications.",
"tags": "web,development",
},
)
# Search for "database" in titles only
search_result = await client.call_tool(
"search_notes",
{
"query": "Database",
"search_type": "title",
},
)
result_text = search_result[0].text
assert "Database Design" in result_text
assert "Web Development" not in result_text # Has "database" in content but not title
@pytest.mark.asyncio
async def test_search_permalink_exact(mcp_server, app):
"""Test exact permalink search."""
async with Client(mcp_server) as client:
# Create test notes
await client.call_tool(
"write_note",
{
"title": "API Documentation",
"folder": "api",
"content": "# API Documentation\n\nComplete API reference guide.",
"tags": "api,docs",
},
)
await client.call_tool(
"write_note",
{
"title": "API Testing",
"folder": "testing",
"content": "# API Testing\n\nHow to test REST APIs.",
"tags": "api,testing",
},
)
# Search for exact permalink
search_result = await client.call_tool(
"search_notes",
{
"query": "api/api-documentation",
"search_type": "permalink",
},
)
result_text = search_result[0].text
assert "API Documentation" in result_text
assert "API Testing" not in result_text
@pytest.mark.asyncio
async def test_search_permalink_pattern(mcp_server, app):
"""Test permalink pattern search with wildcards."""
async with Client(mcp_server) as client:
# Create test notes in different folders
await client.call_tool(
"write_note",
{
"title": "Meeting Notes January",
"folder": "meetings",
"content": "# Meeting Notes January\n\nJanuary team meeting notes.",
"tags": "meetings,january",
},
)
await client.call_tool(
"write_note",
{
"title": "Meeting Notes February",
"folder": "meetings",
"content": "# Meeting Notes February\n\nFebruary team meeting notes.",
"tags": "meetings,february",
},
)
await client.call_tool(
"write_note",
{
"title": "Project Notes",
"folder": "projects",
"content": "# Project Notes\n\nGeneral project documentation.",
"tags": "projects,notes",
},
)
# Search for all meeting notes using pattern
search_result = await client.call_tool(
"search_notes",
{
"query": "meetings/*",
"search_type": "permalink",
},
)
result_text = search_result[0].text
assert "Meeting Notes January" in result_text
assert "Meeting Notes February" in result_text
assert "Project Notes" not in result_text
@pytest.mark.asyncio
async def test_search_entity_type_filter(mcp_server, app):
"""Test filtering search results by entity type."""
async with Client(mcp_server) as client:
# Create a note with observations and relations
content_with_observations = """# Development Process
This describes our development workflow.
## Observations
- [process] We use Git for version control
- [tool] We use VS Code as our editor
## Relations
- uses [[Git]]
- part_of [[Development Workflow]]
Regular content about development practices."""
await client.call_tool(
"write_note",
{
"title": "Development Process",
"folder": "processes",
"content": content_with_observations,
"tags": "development,process",
},
)
# Search for "development" in entities only
search_result = await client.call_tool(
"search_notes",
{
"query": "development",
"entity_types": ["entity"],
},
)
result_text = search_result[0].text
# Should find the main entity but filter out observations/relations
assert "Development Process" in result_text
@pytest.mark.asyncio
async def test_search_pagination(mcp_server, app):
"""Test search result pagination."""
async with Client(mcp_server) as client:
# Create multiple notes to test pagination
for i in range(15):
await client.call_tool(
"write_note",
{
"title": f"Test Note {i + 1:02d}",
"folder": "test",
"content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.",
"tags": "test,pagination",
},
)
# Search with pagination (page 1, page_size 5)
search_result = await client.call_tool(
"search_notes",
{
"query": "test",
"page": 1,
"page_size": 5,
},
)
result_text = search_result[0].text
# Should contain 5 results and pagination info
assert '"current_page": 1' in result_text
assert '"page_size": 5' in result_text
# Search page 2
search_result = await client.call_tool(
"search_notes",
{
"query": "test",
"page": 2,
"page_size": 5,
},
)
result_text = search_result[0].text
assert '"current_page": 2' in result_text
@pytest.mark.asyncio
async def test_search_no_results(mcp_server, app):
"""Test search with no matching results."""
async with Client(mcp_server) as client:
# Create a test note
await client.call_tool(
"write_note",
{
"title": "Sample Note",
"folder": "test",
"content": "# Sample Note\n\nThis is a sample note for testing.",
"tags": "sample,test",
},
)
# Search for something that doesn't exist
search_result = await client.call_tool(
"search_notes",
{
"query": "nonexistent",
},
)
result_text = search_result[0].text
assert '"results": []' in result_text or '"results":[]' in result_text
@pytest.mark.asyncio
async def test_search_complex_boolean_query(mcp_server, app):
"""Test complex boolean queries with grouping."""
async with Client(mcp_server) as client:
# Create test notes
await client.call_tool(
"write_note",
{
"title": "Python Web Development",
"folder": "tutorials",
"content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.",
"tags": "python,web,development",
},
)
await client.call_tool(
"write_note",
{
"title": "Python Data Science",
"folder": "tutorials",
"content": "# Python Data Science\n\nData analysis and machine learning with Python.",
"tags": "python,data,science",
},
)
await client.call_tool(
"write_note",
{
"title": "JavaScript Web Development",
"folder": "tutorials",
"content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.",
"tags": "javascript,web,development",
},
)
# Complex boolean query: (Python OR JavaScript) AND web
search_result = await client.call_tool(
"search_notes",
{
"query": "(Python OR JavaScript) AND web",
},
)
result_text = search_result[0].text
assert "Python Web Development" in result_text
assert "JavaScript Web Development" in result_text
assert "Python Data Science" not in result_text # Has Python but not web
@pytest.mark.asyncio
async def test_search_case_insensitive(mcp_server, app):
"""Test that search is case insensitive."""
async with Client(mcp_server) as client:
# Create test note
await client.call_tool(
"write_note",
{
"title": "Machine Learning Guide",
"folder": "guides",
"content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.",
"tags": "ML,AI",
},
)
# Search with different cases
search_cases = ["machine", "MACHINE", "Machine", "learning", "LEARNING"]
for search_term in search_cases:
search_result = await client.call_tool(
"search_notes",
{
"query": search_term,
},
)
result_text = search_result[0].text
assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}"
+284
View File
@@ -0,0 +1,284 @@
"""
Integration tests for write_note MCP tool.
Comprehensive tests covering all scenarios including note creation, content formatting,
tag handling, error conditions, and edge cases from bug reports.
"""
from textwrap import dedent
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_write_note_basic_creation(mcp_server, app):
"""Test creating a simple note with basic content."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"title": "Simple Note",
"folder": "basic",
"content": "# Simple Note\n\nThis is a simple note for testing.",
"tags": "simple,test",
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: basic/Simple Note.md" in response_text
assert "permalink: basic/simple-note" in response_text
assert "## Tags" in response_text
assert "- simple, test" in response_text
@pytest.mark.asyncio
async def test_write_note_no_tags(mcp_server, app):
"""Test creating a note without tags."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"title": "No Tags Note",
"folder": "test",
"content": "Just some plain text without tags.",
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: test/No Tags Note.md" in response_text
assert "permalink: test/no-tags-note" in response_text
# Should not have tags section when no tags provided
@pytest.mark.asyncio
async def test_write_note_update_existing(mcp_server, app):
"""Test updating an existing note."""
async with Client(mcp_server) as client:
# Create initial note
result1 = await client.call_tool(
"write_note",
{
"title": "Update Test",
"folder": "test",
"content": "# Update Test\n\nOriginal content.",
"tags": "original",
},
)
assert "# Created note" in result1[0].text
# Update the same note
result2 = await client.call_tool(
"write_note",
{
"title": "Update Test",
"folder": "test",
"content": "# Update Test\n\nUpdated content with changes.",
"tags": "updated,modified",
},
)
assert len(result2) == 1
assert result2[0].type == "text"
response_text = result2[0].text
assert "# Updated note" in response_text
assert "file_path: test/Update Test.md" in response_text
assert "permalink: test/update-test" in response_text
assert "- updated, modified" in response_text
@pytest.mark.asyncio
async def test_write_note_tag_array(mcp_server, app):
"""Test creating a note with tag array (Issue #38 regression test)."""
async with Client(mcp_server) as client:
# This reproduces the exact bug from Issue #38
result = await client.call_tool(
"write_note",
{
"title": "Array Tags Test",
"folder": "test",
"content": "Testing tag array handling",
"tags": ["python", "testing", "integration", "mcp"],
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: test/Array Tags Test.md" in response_text
assert "permalink: test/array-tags-test" in response_text
assert "## Tags" in response_text
assert "python" in response_text
@pytest.mark.asyncio
async def test_write_note_custom_permalink(mcp_server, app):
"""Test custom permalink handling (Issue #93 regression test)."""
async with Client(mcp_server) as client:
content_with_custom_permalink = dedent("""
---
permalink: custom/my-special-permalink
---
# Custom Permalink Note
This note has a custom permalink in frontmatter.
- [note] Testing custom permalink preservation
""").strip()
result = await client.call_tool(
"write_note",
{
"title": "Custom Permalink Note",
"folder": "notes",
"content": content_with_custom_permalink,
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: notes/Custom Permalink Note.md" in response_text
assert "permalink: custom/my-special-permalink" in response_text
@pytest.mark.asyncio
async def test_write_note_unicode_content(mcp_server, app):
"""Test handling unicode content including emojis."""
async with Client(mcp_server) as client:
unicode_content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦\n\n- [note] Testing unicode handling 测试"
result = await client.call_tool(
"write_note",
{
"title": "Unicode Test 🌟",
"folder": "test",
"content": unicode_content,
"tags": "unicode,emoji,测试",
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: test/Unicode Test 🌟.md" in response_text
# Permalink should be sanitized
assert "permalink: test/unicode-test" in response_text
assert "## Tags" in response_text
@pytest.mark.asyncio
async def test_write_note_complex_content_with_observations_relations(mcp_server, app):
"""Test creating note with complex content including observations and relations."""
async with Client(mcp_server) as client:
complex_content = dedent("""
# Complex Note
This note demonstrates the full knowledge format.
## Observations
- [tech] Uses Python and FastAPI
- [design] Follows MCP protocol specification
- [note] Integration tests are comprehensive
## Relations
- implements [[MCP Protocol]]
- depends_on [[FastAPI Framework]]
- tested_by [[Integration Tests]]
## Additional Content
Some more regular markdown content here.
""").strip()
result = await client.call_tool(
"write_note",
{
"title": "Complex Knowledge Note",
"folder": "knowledge",
"content": complex_content,
"tags": "complex,knowledge,relations",
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: knowledge/Complex Knowledge Note.md" in response_text
assert "permalink: knowledge/complex-knowledge-note" in response_text
# Should show observation and relation counts
assert "## Observations" in response_text
assert "tech: 1" in response_text
assert "design: 1" in response_text
assert "note: 1" in response_text
assert "## Relations" in response_text
# Should show outgoing relations
assert "## Tags" in response_text
assert "complex, knowledge, relations" in response_text
@pytest.mark.asyncio
async def test_write_note_preserve_frontmatter(mcp_server, app):
"""Test that custom frontmatter is preserved when updating notes."""
async with Client(mcp_server) as client:
content_with_frontmatter = dedent("""
---
title: Frontmatter Note
type: note
version: 1.0
author: Test Author
status: draft
---
# Frontmatter Note
This note has custom frontmatter that should be preserved.
""").strip()
result = await client.call_tool(
"write_note",
{
"title": "Frontmatter Note",
"folder": "test",
"content": content_with_frontmatter,
"tags": "frontmatter,preservation",
},
)
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: test/Frontmatter Note.md" in response_text
assert "permalink: test/frontmatter-note" in response_text
+5 -4
View File
@@ -7,16 +7,17 @@ import pytest_asyncio
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from basic_memory.deps import get_project_config, get_engine_factory
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
from basic_memory.models import Project
@pytest_asyncio.fixture
async def app(test_project, test_config, engine_factory) -> FastAPI:
async def app(test_config, engine_factory, app_config) -> FastAPI:
"""Create FastAPI test application."""
from basic_memory.api.app import app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_app_config] = lambda: app_config
app.dependency_overrides[get_project_config] = lambda: test_config.project_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@@ -36,4 +37,4 @@ def project_url(test_project: Project) -> str:
"""
# Make sure this matches what's in tests/conftest.py for test_project creation
# The permalink should be generated from "Test Project Context"
return f"/{test_project.permalink}"
return f"/{test_project.permalink}"
+152
View File
@@ -125,3 +125,155 @@ async def test_get_directory_tree_mocked(client, project_url):
assert folder2["directory_path"] == "/test/folder2"
assert folder2["type"] == "directory"
assert folder2["children"] == []
@pytest.mark.asyncio
async def test_list_directory_endpoint_default(test_graph, client, project_url):
"""Test the list_directory endpoint with default parameters."""
# Call the endpoint with default parameters
response = await client.get(f"{project_url}/directory/list")
# Verify response
assert response.status_code == 200
data = response.json()
# Should return a list
assert isinstance(data, list)
# With test_graph, should return the "test" directory
assert len(data) == 1
assert data[0]["name"] == "test"
assert data[0]["type"] == "directory"
@pytest.mark.asyncio
async def test_list_directory_endpoint_specific_path(test_graph, client, project_url):
"""Test the list_directory endpoint with specific directory path."""
# Call the endpoint with /test directory
response = await client.get(f"{project_url}/directory/list?dir_name=/test")
# Verify response
assert response.status_code == 200
data = response.json()
# Should return list of files in test directory
assert isinstance(data, list)
assert len(data) == 5
# All should be files (no subdirectories in test_graph)
for item in data:
assert item["type"] == "file"
assert item["name"].endswith(".md")
@pytest.mark.asyncio
async def test_list_directory_endpoint_with_glob(test_graph, client, project_url):
"""Test the list_directory endpoint with glob filtering."""
# Call the endpoint with glob filter
response = await client.get(
f"{project_url}/directory/list?dir_name=/test&file_name_glob=*Connected*"
)
# Verify response
assert response.status_code == 200
data = response.json()
# Should return only Connected Entity files
assert isinstance(data, list)
assert len(data) == 2
file_names = {item["name"] for item in data}
assert file_names == {"Connected Entity 1.md", "Connected Entity 2.md"}
@pytest.mark.asyncio
async def test_list_directory_endpoint_with_depth(test_graph, client, project_url):
"""Test the list_directory endpoint with depth control."""
# Test depth=1 (default)
response_depth_1 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=1")
assert response_depth_1.status_code == 200
data_depth_1 = response_depth_1.json()
assert len(data_depth_1) == 1 # Just the test directory
# Test depth=2 (should include files in test directory)
response_depth_2 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=2")
assert response_depth_2.status_code == 200
data_depth_2 = response_depth_2.json()
assert len(data_depth_2) == 6 # test directory + 5 files
@pytest.mark.asyncio
async def test_list_directory_endpoint_nonexistent_path(test_graph, client, project_url):
"""Test the list_directory endpoint with nonexistent directory."""
# Call the endpoint with nonexistent directory
response = await client.get(f"{project_url}/directory/list?dir_name=/nonexistent")
# Verify response
assert response.status_code == 200
data = response.json()
# Should return empty list
assert isinstance(data, list)
assert len(data) == 0
@pytest.mark.asyncio
async def test_list_directory_endpoint_validation_errors(client, project_url):
"""Test the list_directory endpoint with invalid parameters."""
# Test depth too low
response = await client.get(f"{project_url}/directory/list?depth=0")
assert response.status_code == 422 # Validation error
# Test depth too high
response = await client.get(f"{project_url}/directory/list?depth=11")
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_list_directory_endpoint_mocked(client, project_url):
"""Test the list_directory endpoint with mocked service."""
# Create mock directory nodes
mock_nodes = [
DirectoryNode(
name="folder1",
directory_path="/folder1",
type="directory",
),
DirectoryNode(
name="file1.md",
directory_path="/file1.md",
file_path="file1.md",
type="file",
title="File 1",
permalink="file-1",
),
]
# Patch the directory service
with patch(
"basic_memory.services.directory_service.DirectoryService.list_directory",
return_value=mock_nodes,
):
# Call the endpoint
response = await client.get(f"{project_url}/directory/list?dir_name=/test")
# Verify response
assert response.status_code == 200
data = response.json()
# Check structure matches our mock
assert isinstance(data, list)
assert len(data) == 2
# Check directory
folder = next(item for item in data if item["type"] == "directory")
assert folder["name"] == "folder1"
assert folder["directory_path"] == "/folder1"
# Check file
file_item = next(item for item in data if item["type"] == "file")
assert file_item["name"] == "file1.md"
assert file_item["directory_path"] == "/file1.md"
assert file_item["file_path"] == "file1.md"
assert file_item["title"] == "File 1"
assert file_item["permalink"] == "file-1"
+1 -1
View File
@@ -144,7 +144,7 @@ async def create_test_upload_file(tmp_path, content):
@pytest.mark.asyncio
async def test_import_chatgpt(
test_config, client: AsyncClient, tmp_path, chatgpt_json_content, file_service, project_url
project_config, client: AsyncClient, tmp_path, chatgpt_json_content, file_service, project_url
):
"""Test importing ChatGPT conversations."""
# Create a test file
+708
View File
@@ -530,3 +530,711 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
results = search_response.json()["results"]
assert len(results) == 1
assert results[0]["permalink"] == entity.permalink
# PATCH edit entity endpoint tests
@pytest.mark.asyncio
async def test_edit_entity_append(client: AsyncClient, project_url):
"""Test appending content to an entity via PATCH endpoint."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "Original content",
},
)
assert response.status_code == 200
entity = response.json()
# Edit entity with append operation
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "append", "content": "Appended content"},
)
if response.status_code != 200:
print(f"PATCH failed with status {response.status_code}")
print(f"Response content: {response.text}")
assert response.status_code == 200
updated = response.json()
# Verify content was appended by reading the file
response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true")
file_content = response.text
assert "Original content" in file_content
assert "Appended content" in file_content
assert file_content.index("Original content") < file_content.index("Appended content")
@pytest.mark.asyncio
async def test_edit_entity_prepend(client: AsyncClient, project_url):
"""Test prepending content to an entity via PATCH endpoint."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "Original content",
},
)
assert response.status_code == 200
entity = response.json()
# Edit entity with prepend operation
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "prepend", "content": "Prepended content"},
)
if response.status_code != 200:
print(f"PATCH prepend failed with status {response.status_code}")
print(f"Response content: {response.text}")
assert response.status_code == 200
updated = response.json()
# Verify the entire file content structure
response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true")
file_content = response.text
# Expected content with frontmatter preserved and content prepended to body
expected_content = """---
title: Test Note
type: note
permalink: test/test-note
---
Prepended content
Original content"""
assert file_content.strip() == expected_content.strip()
@pytest.mark.asyncio
async def test_edit_entity_find_replace(client: AsyncClient, project_url):
"""Test find and replace operation via PATCH endpoint."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "This is old content that needs updating",
},
)
assert response.status_code == 200
entity = response.json()
# Edit entity with find_replace operation
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "find_replace", "content": "new content", "find_text": "old content"},
)
assert response.status_code == 200
updated = response.json()
# Verify content was replaced
response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true")
file_content = response.text
assert "old content" not in file_content
assert "This is new content that needs updating" in file_content
@pytest.mark.asyncio
async def test_edit_entity_find_replace_with_expected_replacements(
client: AsyncClient, project_url
):
"""Test find and replace with expected_replacements parameter."""
# Create test entity with repeated text
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
)
assert response.status_code == 200
entity = response.json()
# Edit entity with find_replace operation, expecting 2 replacements
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={
"operation": "find_replace",
"content": "apple",
"find_text": "banana",
"expected_replacements": 2,
},
)
assert response.status_code == 200
updated = response.json()
# Verify both instances were replaced
response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true")
file_content = response.text
assert "The word apple appears here. Another apple word here." in file_content
@pytest.mark.asyncio
async def test_edit_entity_replace_section(client: AsyncClient, project_url):
"""Test replacing a section via PATCH endpoint."""
# Create test entity with sections
content = """# Main Title
## Section 1
Original section 1 content
## Section 2
Original section 2 content"""
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"entity_type": "note",
"content": content,
},
)
assert response.status_code == 200
entity = response.json()
# Edit entity with replace_section operation
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={
"operation": "replace_section",
"content": "New section 1 content",
"section": "## Section 1",
},
)
assert response.status_code == 200
updated = response.json()
# Verify section was replaced
response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true")
file_content = response.text
assert "New section 1 content" in file_content
assert "Original section 1 content" not in file_content
assert "Original section 2 content" in file_content # Other sections preserved
@pytest.mark.asyncio
async def test_edit_entity_not_found(client: AsyncClient, project_url):
"""Test editing a non-existent entity returns 400."""
response = await client.patch(
f"{project_url}/knowledge/entities/non-existent",
json={"operation": "append", "content": "content"},
)
assert response.status_code == 400
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_edit_entity_invalid_operation(client: AsyncClient, project_url):
"""Test editing with invalid operation returns 400."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "Original content",
},
)
assert response.status_code == 200
entity = response.json()
# Try invalid operation
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "invalid_operation", "content": "content"},
)
assert response.status_code == 422
assert "invalid_operation" in response.json()["detail"][0]["input"]
@pytest.mark.asyncio
async def test_edit_entity_find_replace_missing_find_text(client: AsyncClient, project_url):
"""Test find_replace without find_text returns 400."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "Original content",
},
)
assert response.status_code == 200
entity = response.json()
# Try find_replace without find_text
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "find_replace", "content": "new content"},
)
assert response.status_code == 400
assert "find_text is required" in response.json()["detail"]
@pytest.mark.asyncio
async def test_edit_entity_replace_section_missing_section(client: AsyncClient, project_url):
"""Test replace_section without section parameter returns 400."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "Original content",
},
)
assert response.status_code == 200
entity = response.json()
# Try replace_section without section
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "replace_section", "content": "new content"},
)
assert response.status_code == 400
assert "section is required" in response.json()["detail"]
@pytest.mark.asyncio
async def test_edit_entity_find_replace_not_found(client: AsyncClient, project_url):
"""Test find_replace when text is not found returns 400."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"entity_type": "note",
"content": "This is some content",
},
)
assert response.status_code == 200
entity = response.json()
# Try to replace text that doesn't exist
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "find_replace", "content": "new content", "find_text": "nonexistent"},
)
assert response.status_code == 400
assert "Text to replace not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_edit_entity_find_replace_wrong_expected_count(client: AsyncClient, project_url):
"""Test find_replace with wrong expected_replacements count returns 400."""
# Create test entity with repeated text
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
)
assert response.status_code == 200
entity = response.json()
# Try to replace with wrong expected count
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={
"operation": "find_replace",
"content": "replacement",
"find_text": "banana",
"expected_replacements": 1, # Wrong - there are actually 2
},
)
assert response.status_code == 400
assert "Expected 1 occurrences" in response.json()["detail"]
assert "but found 2" in response.json()["detail"]
@pytest.mark.asyncio
async def test_edit_entity_search_reindex(client: AsyncClient, project_url):
"""Test that edited entities are reindexed for search."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "Search Test",
"folder": "test",
"entity_type": "note",
"content": "Original searchable content",
},
)
assert response.status_code == 200
entity = response.json()
# Edit the entity
response = await client.patch(
f"{project_url}/knowledge/entities/{entity['permalink']}",
json={"operation": "append", "content": " with unique zebra marker"},
)
assert response.status_code == 200
# Search should find the new content
search_response = await client.post(
f"{project_url}/search/",
json={"text": "zebra marker", "entity_types": ["entity"]},
)
results = search_response.json()["results"]
assert len(results) == 1
assert results[0]["permalink"] == entity["permalink"]
# Move entity endpoint tests
@pytest.mark.asyncio
async def test_move_entity_success(client: AsyncClient, project_url):
"""Test successfully moving an entity to a new location."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"entity_type": "note",
"content": "Test content",
},
)
assert response.status_code == 200
entity = response.json()
original_permalink = entity["permalink"]
# Move entity
move_data = {
"identifier": original_permalink,
"destination_path": "target/MovedNote.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
response_model = EntityResponse.model_validate(response.json())
assert response_model.file_path == "target/MovedNote.md"
# Verify original entity no longer exists
response = await client.get(f"{project_url}/knowledge/entities/{original_permalink}")
assert response.status_code == 404
# Verify entity exists at new location
response = await client.get(f"{project_url}/knowledge/entities/target/moved-note")
assert response.status_code == 200
moved_entity = response.json()
assert moved_entity["file_path"] == "target/MovedNote.md"
assert moved_entity["permalink"] == "target/moved-note"
# Verify file content using resource endpoint
response = await client.get(f"{project_url}/resource/target/moved-note?content=true")
assert response.status_code == 200
file_content = response.text
assert "Test content" in file_content
@pytest.mark.asyncio
async def test_move_entity_with_folder_creation(client: AsyncClient, project_url):
"""Test moving entity creates necessary folders."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"entity_type": "note",
"content": "Test content",
},
)
assert response.status_code == 200
entity = response.json()
# Move to deeply nested path
move_data = {
"identifier": entity["permalink"],
"destination_path": "deeply/nested/folder/MovedNote.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
# Verify entity exists at new location
response = await client.get(f"{project_url}/knowledge/entities/deeply/nested/folder/moved-note")
assert response.status_code == 200
moved_entity = response.json()
assert moved_entity["file_path"] == "deeply/nested/folder/MovedNote.md"
@pytest.mark.asyncio
async def test_move_entity_with_observations_and_relations(client: AsyncClient, project_url):
"""Test moving entity preserves observations and relations."""
# Create test entity with complex content
content = """# Complex Entity
## Observations
- [note] Important observation #tag1
- [feature] Key feature #feature
- relation to [[SomeOtherEntity]]
- depends on [[Dependency]]
Some additional content."""
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "ComplexEntity",
"folder": "source",
"entity_type": "note",
"content": content,
},
)
assert response.status_code == 200
entity = response.json()
# Verify original observations and relations
assert len(entity["observations"]) == 2
assert len(entity["relations"]) == 2
# Move entity
move_data = {
"identifier": entity["permalink"],
"destination_path": "target/MovedComplex.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
# Verify moved entity preserves data
response = await client.get(f"{project_url}/knowledge/entities/target/moved-complex")
assert response.status_code == 200
moved_entity = response.json()
# Check observations preserved
assert len(moved_entity["observations"]) == 2
obs_categories = {obs["category"] for obs in moved_entity["observations"]}
assert obs_categories == {"note", "feature"}
# Check relations preserved
assert len(moved_entity["relations"]) == 2
rel_types = {rel["relation_type"] for rel in moved_entity["relations"]}
assert rel_types == {"relation to", "depends on"}
# Verify file content preserved
response = await client.get(f"{project_url}/resource/target/moved-complex?content=true")
assert response.status_code == 200
file_content = response.text
assert "Important observation #tag1" in file_content
assert "[[SomeOtherEntity]]" in file_content
@pytest.mark.asyncio
async def test_move_entity_search_reindexing(client: AsyncClient, project_url):
"""Test that moved entities are properly reindexed for search."""
# Create searchable entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "SearchableNote",
"folder": "source",
"entity_type": "note",
"content": "Unique searchable elephant content",
},
)
assert response.status_code == 200
entity = response.json()
# Move entity
move_data = {
"identifier": entity["permalink"],
"destination_path": "target/MovedSearchable.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
# Search should find entity at new location
search_response = await client.post(
f"{project_url}/search/",
json={"text": "elephant", "entity_types": [SearchItemType.ENTITY.value]},
)
results = search_response.json()["results"]
assert len(results) == 1
assert results[0]["permalink"] == "target/moved-searchable"
@pytest.mark.asyncio
async def test_move_entity_not_found(client: AsyncClient, project_url):
"""Test moving non-existent entity returns 400 error."""
move_data = {
"identifier": "non-existent-entity",
"destination_path": "target/SomeFile.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 400
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_move_entity_invalid_destination_path(client: AsyncClient, project_url):
"""Test moving entity with invalid destination path."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"entity_type": "note",
"content": "Test content",
},
)
assert response.status_code == 200
entity = response.json()
# Test various invalid paths
invalid_paths = [
"/absolute/path.md", # Absolute path
"../parent/path.md", # Parent directory
"", # Empty string
" ", # Whitespace only
]
for invalid_path in invalid_paths:
move_data = {
"identifier": entity["permalink"],
"destination_path": invalid_path,
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_move_entity_destination_exists(client: AsyncClient, project_url):
"""Test moving entity to existing destination returns error."""
# Create source entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "SourceNote",
"folder": "source",
"entity_type": "note",
"content": "Source content",
},
)
assert response.status_code == 200
source_entity = response.json()
# Create destination entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "DestinationNote",
"folder": "target",
"entity_type": "note",
"content": "Destination content",
},
)
assert response.status_code == 200
# Try to move source to existing destination
move_data = {
"identifier": source_entity["permalink"],
"destination_path": "target/DestinationNote.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 400
assert "already exists" in response.json()["detail"]
@pytest.mark.asyncio
async def test_move_entity_missing_identifier(client: AsyncClient, project_url):
"""Test move request with missing identifier."""
move_data = {
"destination_path": "target/SomeFile.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_move_entity_missing_destination(client: AsyncClient, project_url):
"""Test move request with missing destination path."""
move_data = {
"identifier": "some-entity",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_move_entity_by_file_path(client: AsyncClient, project_url):
"""Test moving entity using file path as identifier."""
# Create test entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"entity_type": "note",
"content": "Test content",
},
)
assert response.status_code == 200
entity = response.json()
# Move using file path as identifier
move_data = {
"identifier": entity["file_path"],
"destination_path": "target/MovedByPath.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
# Verify entity exists at new location
response = await client.get(f"{project_url}/knowledge/entities/target/moved-by-path")
assert response.status_code == 200
moved_entity = response.json()
assert moved_entity["file_path"] == "target/MovedByPath.md"
@pytest.mark.asyncio
async def test_move_entity_by_title(client: AsyncClient, project_url):
"""Test moving entity using title as identifier."""
# Create test entity with unique title
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": "UniqueTestTitle",
"folder": "source",
"entity_type": "note",
"content": "Test content",
},
)
assert response.status_code == 200
# Move using title as identifier
move_data = {
"identifier": "UniqueTestTitle",
"destination_path": "target/MovedByTitle.md",
}
response = await client.post(f"{project_url}/knowledge/move", json=move_data)
assert response.status_code == 200
# Verify entity exists at new location
response = await client.get(f"{project_url}/knowledge/entities/target/moved-by-title")
assert response.status_code == 200
moved_entity = response.json()
assert moved_entity["file_path"] == "target/MovedByTitle.md"
assert moved_entity["title"] == "UniqueTestTitle"
+60 -12
View File
@@ -7,7 +7,7 @@ import pytest
@pytest.mark.asyncio
async def test_get_project_info_endpoint(test_graph, client, test_config, project_url):
async def test_get_project_info_endpoint(test_graph, client, project_config, project_url):
"""Test the project-info endpoint returns correctly structured data."""
# Set up some test data in the database
@@ -51,7 +51,7 @@ async def test_get_project_info_endpoint(test_graph, client, test_config, projec
@pytest.mark.asyncio
async def test_get_project_info_content(test_graph, client, test_config, project_url):
async def test_get_project_info_content(test_graph, client, project_config, project_url):
"""Test that project-info contains actual data from the test database."""
# Call the endpoint
response = await client.get(f"{project_url}/project/info")
@@ -77,7 +77,7 @@ async def test_get_project_info_content(test_graph, client, test_config, project
@pytest.mark.asyncio
async def test_get_project_info_watch_status(test_graph, client, test_config, project_url):
async def test_get_project_info_watch_status(test_graph, client, project_config, project_url):
"""Test that project-info correctly handles watch status."""
# Create a mock watch status file
mock_watch_status = {
@@ -111,10 +111,10 @@ async def test_get_project_info_watch_status(test_graph, client, test_config, pr
@pytest.mark.asyncio
async def test_list_projects_endpoint(test_graph, client, test_config, project_url):
async def test_list_projects_endpoint(test_config, test_graph, client, project_config, project_url):
"""Test the list projects endpoint returns correctly structured data."""
# Call the endpoint
response = await client.get(f"{project_url}/project/projects")
response = await client.get("/projects/projects")
# Verify response
assert response.status_code == 200
@@ -123,7 +123,6 @@ async def test_list_projects_endpoint(test_graph, client, test_config, project_u
# Check that the response contains expected fields
assert "projects" in data
assert "default_project" in data
assert "current_project" in data
# Check that projects is a list
assert isinstance(data["projects"], list)
@@ -137,14 +136,63 @@ async def test_list_projects_endpoint(test_graph, client, test_config, project_u
assert "name" in project
assert "path" in project
assert "is_default" in project
assert "is_current" in project
# Current project should be marked
current_project = next((p for p in data["projects"] if p["is_current"]), None)
assert current_project is not None
assert current_project["name"] == data["current_project"]
# Default project should be marked
default_project = next((p for p in data["projects"] if p["is_default"]), None)
assert default_project is not None
assert default_project["name"] == data["default_project"]
@pytest.mark.asyncio
async def test_remove_project_endpoint(test_config, client, project_service):
"""Test the remove project endpoint."""
# First create a test project to remove
test_project_name = "test-remove-project"
await project_service.add_project(test_project_name, "/tmp/test-remove-project")
# Verify it exists
project = await project_service.get_project(test_project_name)
assert project is not None
# Remove the project
response = await client.delete(f"/projects/{test_project_name}")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert "old_project" in data
assert data["old_project"]["name"] == test_project_name
# Verify project is actually removed
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
@pytest.mark.asyncio
async def test_set_default_project_endpoint(test_config, client, project_service):
"""Test the set default project endpoint."""
# Create a test project to set as default
test_project_name = "test-default-project"
await project_service.add_project(test_project_name, "/tmp/test-default-project")
# Set it as default
response = await client.put(f"/projects/{test_project_name}/default")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert "new_project" in data
assert data["new_project"]["name"] == test_project_name
# Verify it's actually set as default
assert project_service.default_project == test_project_name
+1 -1
View File
@@ -30,7 +30,7 @@ async def test_get_project_info_additional(client, test_graph, project_url):
async def test_project_list_additional(client, project_url):
"""Test additional fields in the project list endpoint."""
# Call the endpoint
response = await client.get(f"{project_url}/project/projects")
response = await client.get("/projects/projects")
# Verify response
assert response.status_code == 200
+16 -16
View File
@@ -10,11 +10,11 @@ from basic_memory.schemas import EntityResponse
@pytest.mark.asyncio
async def test_get_resource_content(client, test_config, entity_repository, project_url):
async def test_get_resource_content(client, project_config, entity_repository, project_url):
"""Test getting content by permalink."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file = Path(project_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
@@ -39,11 +39,11 @@ async def test_get_resource_content(client, test_config, entity_repository, proj
@pytest.mark.asyncio
async def test_get_resource_pagination(client, test_config, entity_repository, project_url):
async def test_get_resource_pagination(client, project_config, entity_repository, project_url):
"""Test getting content by permalink with pagination."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file = Path(project_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
@@ -70,11 +70,11 @@ async def test_get_resource_pagination(client, test_config, entity_repository, p
@pytest.mark.asyncio
async def test_get_resource_by_title(client, test_config, entity_repository, project_url):
async def test_get_resource_by_title(client, project_config, entity_repository, project_url):
"""Test getting content by permalink."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file = Path(project_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
@@ -105,7 +105,7 @@ async def test_get_resource_missing_entity(client, project_url):
@pytest.mark.asyncio
async def test_get_resource_missing_file(client, test_config, entity_repository, project_url):
async def test_get_resource_missing_file(client, project_config, entity_repository, project_url):
"""Test 404 when file doesn't exist."""
# Create entity referencing non-existent file
entity = await entity_repository.create(
@@ -126,7 +126,7 @@ async def test_get_resource_missing_file(client, test_config, entity_repository,
@pytest.mark.asyncio
async def test_get_resource_observation(client, test_config, entity_repository, project_url):
async def test_get_resource_observation(client, project_config, entity_repository, project_url):
"""Test getting content by observation permalink."""
# Create entity
content = "# Test Content\n\n- [note] an observation."
@@ -164,7 +164,7 @@ permalink: test/test-entity
@pytest.mark.asyncio
async def test_get_resource_entities(client, test_config, entity_repository, project_url):
async def test_get_resource_entities(client, project_config, entity_repository, project_url):
"""Test getting content by permalink match."""
# Create entity
content1 = "# Test Content\n"
@@ -213,7 +213,7 @@ async def test_get_resource_entities(client, test_config, entity_repository, pro
@pytest.mark.asyncio
async def test_get_resource_entities_pagination(
client, test_config, entity_repository, project_url
client, project_config, entity_repository, project_url
):
"""Test getting content by permalink match."""
# Create entity
@@ -264,7 +264,7 @@ permalink: test/related-entity
@pytest.mark.asyncio
async def test_get_resource_relation(client, test_config, entity_repository, project_url):
async def test_get_resource_relation(client, project_config, entity_repository, project_url):
"""Test getting content by relation permalink."""
# Create entity
content1 = "# Test Content\n"
@@ -314,7 +314,7 @@ async def test_get_resource_relation(client, test_config, entity_repository, pro
@pytest.mark.asyncio
async def test_put_resource_new_file(
client, test_config, entity_repository, search_repository, project_url
client, project_config, entity_repository, search_repository, project_url
):
"""Test creating a new file via PUT."""
# Test data
@@ -335,7 +335,7 @@ async def test_put_resource_new_file(
}
# Make sure the file doesn't exist yet
full_path = Path(test_config.home) / file_path
full_path = Path(project_config.home) / file_path
if full_path.exists():
full_path.unlink()
@@ -352,7 +352,7 @@ async def test_put_resource_new_file(
assert "size" in response_data
# Verify file was created
full_path = Path(test_config.home) / file_path
full_path = Path(project_config.home) / file_path
assert full_path.exists()
# Verify file content
@@ -371,11 +371,11 @@ async def test_put_resource_new_file(
@pytest.mark.asyncio
async def test_put_resource_update_existing(client, test_config, entity_repository, project_url):
async def test_put_resource_update_existing(client, project_config, entity_repository, project_url):
"""Test updating an existing file via PUT."""
# Create an initial file and entity
file_path = "visualizations/update-test.canvas"
full_path = Path(test_config.home) / file_path
full_path = Path(project_config.home) / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
initial_data = {
+8 -6
View File
@@ -6,14 +6,15 @@ from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_project_config, get_engine_factory
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
@pytest_asyncio.fixture
def app(test_config, engine_factory) -> FastAPI:
@pytest_asyncio.fixture(autouse=True)
async def app(app_config, project_config, engine_factory, test_config) -> FastAPI:
"""Create test FastAPI application."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_app_config] = lambda: app_config
app.dependency_overrides[get_project_config] = lambda: project_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@@ -26,5 +27,6 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
@pytest.fixture
def cli_env(test_config, client):
pass
def cli_env(project_config, client, test_config):
"""Set up CLI environment with correct project session."""
return {"project_config": project_config, "client": client}
+5 -5
View File
@@ -218,7 +218,7 @@ class TestAuthCommands:
mock_provider_class.assert_called_once_with(issuer_url="https://custom-issuer.com")
# Should exit early due to client not found
assert "Error: Client not found after registration" in result.stdout
assert "Error: Client not found after registration" in result.stderr
def test_test_auth_client_not_found(self, runner, mock_provider):
"""Test OAuth test flow when client is not found after registration."""
@@ -236,7 +236,7 @@ class TestAuthCommands:
result = runner.invoke(auth_app, ["test-auth"])
assert result.exit_code == 0 # Command completes but with error message
assert "Error: Client not found after registration" in result.stdout
assert "Error: Client not found after registration" in result.stderr
def test_test_auth_no_auth_code_in_url(self, runner, mock_provider):
"""Test OAuth test flow when no auth code in URL."""
@@ -265,7 +265,7 @@ class TestAuthCommands:
result = runner.invoke(auth_app, ["test-auth"])
assert result.exit_code == 0
assert "Error: No authorization code in URL" in result.stdout
assert "Error: No authorization code in URL" in result.stderr
def test_test_auth_invalid_auth_code(self, runner, mock_provider):
"""Test OAuth test flow when authorization code is invalid."""
@@ -295,7 +295,7 @@ class TestAuthCommands:
result = runner.invoke(auth_app, ["test-auth"])
assert result.exit_code == 0
assert "Error: Invalid authorization code" in result.stdout
assert "Error: Invalid authorization code" in result.stderr
def test_test_auth_invalid_access_token(self, runner, mock_provider):
"""Test OAuth test flow when access token validation fails."""
@@ -336,7 +336,7 @@ class TestAuthCommands:
assert result.exit_code == 0
assert "Access token: test-access-token" in result.stdout
assert "Error: Invalid access token" in result.stdout
assert "Error: Invalid access token" in result.stderr
def test_test_auth_exception_handling(self, runner, mock_provider):
"""Test OAuth test flow exception handling."""
+10 -10
View File
@@ -56,7 +56,7 @@ async def setup_test_note(entity_service, search_service) -> AsyncGenerator[dict
}
def test_write_note(cli_env, test_config):
def test_write_note(cli_env, project_config):
"""Test write_note command with basic arguments."""
result = runner.invoke(
tool_app,
@@ -78,7 +78,7 @@ def test_write_note(cli_env, test_config):
assert "permalink" in result.stdout
def test_write_note_with_tags(cli_env, test_config):
def test_write_note_with_tags(cli_env, project_config):
"""Test write_note command with tags."""
result = runner.invoke(
tool_app,
@@ -103,7 +103,7 @@ def test_write_note_with_tags(cli_env, test_config):
assert "tag1, tag2" in result.stdout or "tag1" in result.stdout and "tag2" in result.stdout
def test_write_note_from_stdin(cli_env, test_config, monkeypatch):
def test_write_note_from_stdin(cli_env, project_config, monkeypatch):
"""Test write_note command reading from stdin.
This test requires minimal mocking of stdin to simulate piped input.
@@ -135,7 +135,7 @@ def test_write_note_from_stdin(cli_env, test_config, monkeypatch):
assert "permalink" in result.stdout
def test_write_note_content_param_priority(cli_env, test_config):
def test_write_note_content_param_priority(cli_env, project_config):
"""Test that content parameter has priority over stdin."""
stdin_content = "This content from stdin should NOT be used"
param_content = "This explicit content parameter should be used"
@@ -167,7 +167,7 @@ def test_write_note_content_param_priority(cli_env, test_config):
assert "Created" in result.stdout or "Updated" in result.stdout
def test_write_note_no_content(cli_env, test_config):
def test_write_note_no_content(cli_env, project_config):
"""Test error handling when no content is provided."""
# Mock stdin to appear as a terminal, not a pipe
with patch("sys.stdin.isatty", return_value=True):
@@ -417,19 +417,19 @@ def test_continue_conversation_no_results(cli_env):
@patch("basic_memory.services.initialization.initialize_database")
def test_ensure_migrations_functionality(mock_initialize_database, test_config, monkeypatch):
def test_ensure_migrations_functionality(mock_initialize_database, project_config, monkeypatch):
"""Test the database initialization functionality."""
from basic_memory.services.initialization import ensure_initialization
# Call the function
ensure_initialization(test_config)
ensure_initialization(project_config)
# The underlying asyncio.run should call our mocked function
mock_initialize_database.assert_called_once()
@patch("basic_memory.services.initialization.initialize_database")
def test_ensure_migrations_handles_errors(mock_initialize_database, test_config, monkeypatch):
def test_ensure_migrations_handles_errors(mock_initialize_database, project_config, monkeypatch):
"""Test that initialization handles errors gracefully."""
from basic_memory.services.initialization import ensure_initialization
@@ -437,6 +437,6 @@ def test_ensure_migrations_handles_errors(mock_initialize_database, test_config,
mock_initialize_database.side_effect = Exception("Test error")
# Call the function - should not raise exception
ensure_initialization(test_config)
ensure_initialization(project_config)
# We're just making sure it doesn't crash by calling it
# We're just making sure it doesn't crash by calling it
+1 -29
View File
@@ -14,9 +14,7 @@ def test_project_list_command(mock_run, cli_env):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{"name": "test", "path": "/path/to/test", "is_default": True, "is_current": True}
],
"projects": [{"name": "test", "path": "/path/to/test", "is_default": True}],
"default_project": "test",
"current_project": "test",
}
@@ -29,28 +27,6 @@ def test_project_list_command(mock_run, cli_env):
assert result.exit_code == 0
@patch("basic_memory.cli.commands.project.asyncio.run")
def test_project_current_command(mock_run, cli_env):
"""Test the 'project current' command with mocked API."""
# Mock the API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{"name": "test", "path": "/path/to/test", "is_default": True, "is_current": True}
],
"default_project": "test",
"current_project": "test",
}
mock_run.return_value = mock_response
runner = CliRunner()
result = runner.invoke(cli_app, ["project", "current"])
# Just verify it runs without exception
assert result.exit_code == 0
@patch("basic_memory.cli.commands.project.asyncio.run")
def test_project_add_command(mock_run, cli_env):
"""Test the 'project add' command with mocked API."""
@@ -153,7 +129,6 @@ def test_project_failure_exits_with_error(mock_run, cli_env):
list_result = runner.invoke(cli_app, ["project", "list"])
add_result = runner.invoke(cli_app, ["project", "add", "test-project", "/path/to/project"])
remove_result = runner.invoke(cli_app, ["project", "remove", "test-project"])
current_result = runner.invoke(cli_app, ["project", "current"])
default_result = runner.invoke(cli_app, ["project", "default", "test-project"])
# All should exit with code 1 and show error message
@@ -167,8 +142,5 @@ def test_project_failure_exits_with_error(mock_run, cli_env):
assert remove_result.exit_code == 1
assert "Error removing project" in remove_result.output
assert current_result.exit_code == 1
assert "Error getting current project" in current_result.output
assert default_result.exit_code == 1
assert "Error setting default project" in default_result.output
+6 -7
View File
@@ -8,13 +8,14 @@ from basic_memory.cli.main import app as cli_app
from basic_memory.config import config
def test_info_stats_command(cli_env, test_graph):
"""Test the 'info stats' command with default output."""
def test_info_stats_command(cli_env, test_graph, project_session):
"""Test the 'project info' command with default output."""
runner = CliRunner()
# Run the command
result = runner.invoke(cli_app, ["project", "info"])
# Verify exit code
assert result.exit_code == 0
@@ -22,11 +23,9 @@ def test_info_stats_command(cli_env, test_graph):
assert "Basic Memory Project Info" in result.stdout
def test_info_stats_json(cli_env, test_graph, app_config, test_project):
"""Test the 'info stats --json' command for JSON output."""
def test_info_stats_json(cli_env, test_graph, project_session):
"""Test the 'project info --json' command for JSON output."""
runner = CliRunner()
config.name = test_project.name
config.home = test_project.path
# Run the command with --json flag
result = runner.invoke(cli_app, ["project", "info", "--json"])
@@ -38,4 +37,4 @@ def test_info_stats_json(cli_env, test_graph, app_config, test_project):
output = json.loads(result.stdout)
# Verify JSON structure matches our sample data
assert output["project_name"] == test_project.name
assert output["default_project"] == "test-project"
+1 -1
View File
@@ -17,7 +17,7 @@ from basic_memory.sync.sync_service import SyncReport
runner = CliRunner()
def test_status_command(tmp_path, app_config, test_config, test_project):
def test_status_command(tmp_path, app_config, project_config, test_project):
"""Test CLI status command."""
config.home = tmp_path
config.name = test_project.name
+5 -5
View File
@@ -71,14 +71,14 @@ def test_display_detailed_sync_results_with_changes():
@pytest.mark.asyncio
async def test_run_sync_basic(sync_service, test_config, test_project):
async def test_run_sync_basic(sync_service, project_config, test_project):
"""Test basic sync operation."""
# Set up test environment
config.home = test_config.home
config.home = project_config.home
config.name = test_project.name
# Create test files
test_file = test_config.home / "test.md"
test_file = project_config.home / "test.md"
test_file.write_text("""---
title: Test
---
@@ -89,9 +89,9 @@ Some content""")
await run_sync(verbose=True)
def test_sync_command(sync_service, test_config, test_project):
def test_sync_command(sync_service, project_config, test_project):
"""Test the sync command."""
config.home = test_config.home
config.home = project_config.home
config.name = test_project.name
result = runner.invoke(app, ["sync", "--verbose"])
+113 -37
View File
@@ -1,17 +1,21 @@
"""Common test fixtures."""
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from textwrap import dedent
from typing import AsyncGenerator
from unittest import mock
from unittest.mock import patch
import pytest
import pytest_asyncio
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
import basic_memory.mcp.project_session
from basic_memory import db
from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
from basic_memory.db import DatabaseType
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
@@ -34,7 +38,6 @@ from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync.sync_service import SyncService
from basic_memory.sync.watch_service import WatchService
from basic_memory.config import app_config as basic_memory_app_config # noqa: F401
@pytest.fixture
@@ -46,27 +49,103 @@ def anyio_backend():
def project_root() -> Path:
return Path(__file__).parent.parent
@pytest.fixture
def app_config(test_config: ProjectConfig, monkeypatch) -> BasicMemoryConfig:
projects = {test_config.name: str(test_config.home)}
app_config = BasicMemoryConfig(env="test", projects=projects, default_project=test_config.name)
def config_home(tmp_path, monkeypatch) -> Path:
# Patch HOME environment variable for the duration of the test
monkeypatch.setenv("HOME", str(tmp_path))
return tmp_path
# set the module app_config instance project list
basic_memory_app_config.projects = projects
basic_memory_app_config.default_project = test_config.name
@pytest.fixture(scope="function", autouse=True)
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
# Create a basic config without depending on test_project to avoid circular dependency
projects = {"test-project": str(config_home)}
app_config = BasicMemoryConfig(env="test", projects=projects, default_project="test-project", update_permalinks_on_move=True)
# Patch the module app_config instance for the duration of the test
monkeypatch.setattr("basic_memory.config.app_config", app_config)
return app_config
@pytest.fixture(autouse=True)
def config_manager(app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch) -> ConfigManager:
# Create a new ConfigManager that uses the test home directory
config_manager = ConfigManager()
# Update its paths to use the test directory
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Override the config directly instead of relying on disk load
config_manager.config = app_config
# Ensure the config file is written to disk
config_manager.save_config(app_config)
# Patch the config_manager in all locations where it's imported
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
# Mock get_project_config to return test project config for test-project, fallback for others
def mock_get_project_config(project_name=None):
if project_name == "test-project" or project_name is None:
return project_config
# For any other project name, return a default config pointing to test location
fallback_config = ProjectConfig(name=project_name or "main", home=Path(config_home))
return fallback_config
monkeypatch.setattr("basic_memory.mcp.project_session.get_project_config", mock_get_project_config)
# Patch the project config that CLI commands import (only modules that actually import config)
monkeypatch.setattr("basic_memory.cli.commands.project.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.sync.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.status.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_memory_json.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_claude_projects.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_claude_conversations.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_chatgpt.config", project_config)
return config_manager
@pytest.fixture(autouse=True)
def project_session(test_project: Project):
# initialize the project session with the test project
basic_memory.mcp.project_session.session.initialize(test_project.name)
# Explicitly set current project as well to ensure it's used
basic_memory.mcp.project_session.session.set_current_project(test_project.name)
return basic_memory.mcp.project_session.session
@pytest.fixture(scope="function", autouse=True)
def project_config(test_project, monkeypatch):
"""Create test project configuration."""
project_config = ProjectConfig(
name=test_project.name,
home=Path(test_project.path),
)
# Patch the config module project config for the duration of the test
monkeypatch.setattr("basic_memory.config.config", project_config)
return project_config
@dataclass
class TestConfig:
config_home: Path
project_config: ProjectConfig
app_config: BasicMemoryConfig
config_manager: ConfigManager
@pytest.fixture
def test_config(tmp_path) -> ProjectConfig:
"""Test configuration using in-memory DB."""
config = ProjectConfig(name="test-project", home=tmp_path)
(tmp_path / config.home.name).mkdir(parents=True, exist_ok=True)
logger.info(f"project config home: {config.home}")
return config
def test_config(config_home, project_config, app_config, config_manager) -> TestConfig:
"""All test configuration fixtures"""
@dataclass
class TestConfig:
config_home: Path
project_config: ProjectConfig
app_config: BasicMemoryConfig
config_manager: ConfigManager
return TestConfig(config_home, project_config, app_config, config_manager)
@pytest_asyncio.fixture(scope="function")
@@ -127,17 +206,18 @@ async def project_repository(
@pytest_asyncio.fixture(scope="function")
async def test_project(test_config, project_repository: ProjectRepository) -> Project:
async def test_project(config_home, engine_factory) -> Project:
"""Create a test project to be used as context for other repositories."""
project_data = {
"name": test_config.name,
"name": "test-project",
"description": "Project used as context for tests",
"path": str(test_config.home),
"path": str(config_home),
"is_active": True,
"is_default": True, # Explicitly set as the default project
}
engine, session_maker = engine_factory
project_repository = ProjectRepository(session_maker)
project = await project_repository.create(project_data)
logger.info(f"Created test project with permalink: {project.permalink}")
return project
@@ -165,9 +245,11 @@ async def entity_service(
@pytest.fixture
def file_service(test_config: ProjectConfig, markdown_processor: MarkdownProcessor) -> FileService:
def file_service(
project_config: ProjectConfig, markdown_processor: MarkdownProcessor
) -> FileService:
"""Create FileService instance."""
return FileService(test_config.home, markdown_processor)
return FileService(project_config.home, markdown_processor)
@pytest.fixture
@@ -183,9 +265,9 @@ def link_resolver(entity_repository: EntityRepository, search_service: SearchSer
@pytest.fixture
def entity_parser(test_config):
def entity_parser(project_config):
"""Create parser instance."""
return EntityParser(test_config.home)
return EntityParser(project_config.home)
@pytest_asyncio.fixture
@@ -211,7 +293,7 @@ async def sync_service(
@pytest_asyncio.fixture
async def directory_service(entity_repository, test_config) -> DirectoryService:
async def directory_service(entity_repository, project_config) -> DirectoryService:
"""Create directory service for testing."""
return DirectoryService(
entity_repository=entity_repository,
@@ -275,7 +357,6 @@ async def full_entity(sample_entity, entity_repository, file_service, entity_ser
title="Search_Entity",
folder="test",
entity_type="test",
project=entity_repository.project_id,
content=dedent("""
## Observations
- [tech] Tech note
@@ -307,7 +388,6 @@ async def test_graph(
title="Deeper Entity",
entity_type="deeper",
folder="test",
project=entity_repository.project_id,
content=dedent("""
# Deeper Entity
"""),
@@ -319,7 +399,6 @@ async def test_graph(
title="Deep Entity",
entity_type="deep",
folder="test",
project=entity_repository.project_id,
content=dedent("""
# Deep Entity
- deeper_connection [[Deeper Entity]]
@@ -332,7 +411,6 @@ async def test_graph(
title="Connected Entity 2",
entity_type="test",
folder="test",
project=entity_repository.project_id,
content=dedent("""
# Connected Entity 2
- deep_connection [[Deep Entity]]
@@ -345,7 +423,6 @@ async def test_graph(
title="Connected Entity 1",
entity_type="test",
folder="test",
project=entity_repository.project_id,
content=dedent("""
# Connected Entity 1
- [note] Connected 1 note
@@ -359,7 +436,6 @@ async def test_graph(
title="Root",
entity_type="test",
folder="test",
project=entity_repository.project_id,
content=dedent("""
# Root Entity
- [note] Root note 1
@@ -393,7 +469,7 @@ def watch_service(app_config: BasicMemoryConfig, project_repository) -> WatchSer
@pytest.fixture
def test_files(test_config, project_root) -> dict[str, Path]:
def test_files(project_config, project_root) -> dict[str, Path]:
"""Copy test files into the project directory.
Returns a dict mapping file names to their paths in the project dir.
@@ -411,7 +487,7 @@ def test_files(test_config, project_root) -> dict[str, Path]:
content = src_path.read_bytes()
# Create destination path and ensure parent dirs exist
dest_path = test_config.home / src_path.name
dest_path = project_config.home / src_path.name
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
@@ -422,7 +498,7 @@ def test_files(test_config, project_root) -> dict[str, Path]:
@pytest_asyncio.fixture
async def synced_files(sync_service, test_config, test_files):
async def synced_files(sync_service, project_config, test_files):
# Initial sync - should create forward reference
await sync_service.sync(test_config.home)
return test_files
await sync_service.sync(project_config.home)
return test_files
-19
View File
@@ -1,19 +0,0 @@
"""Test file for experimenting with edit_file."""
def function_one():
"""First test function."""
print("Hello from function one")
# Some code here
x = 1 + 2
return x
def function_two():
"""Second test function."""
print("Hello from function two")
# Some more code
y = 3 * 4
return y
+10 -10
View File
@@ -41,9 +41,9 @@ def valid_entity_content():
@pytest.mark.asyncio
async def test_parse_complete_file(test_config, entity_parser, valid_entity_content):
async def test_parse_complete_file(project_config, entity_parser, valid_entity_content):
"""Test parsing a complete entity file with all features."""
test_file = test_config.home / "test_entity.md"
test_file = project_config.home / "test_entity.md"
test_file.write_text(valid_entity_content)
entity = await entity_parser.parse_file(test_file)
@@ -95,7 +95,7 @@ async def test_parse_complete_file(test_config, entity_parser, valid_entity_cont
@pytest.mark.asyncio
async def test_parse_minimal_file(test_config, entity_parser):
async def test_parse_minimal_file(project_config, entity_parser):
"""Test parsing a minimal valid entity file."""
content = dedent("""
---
@@ -112,7 +112,7 @@ async def test_parse_minimal_file(test_config, entity_parser):
- references [[Other Entity]]
""")
test_file = test_config.home / "minimal.md"
test_file = project_config.home / "minimal.md"
test_file.write_text(content)
entity = await entity_parser.parse_file(test_file)
@@ -127,7 +127,7 @@ async def test_parse_minimal_file(test_config, entity_parser):
@pytest.mark.asyncio
async def test_error_handling(test_config, entity_parser):
async def test_error_handling(project_config, entity_parser):
"""Test error handling."""
# Missing file
@@ -135,7 +135,7 @@ async def test_error_handling(test_config, entity_parser):
await entity_parser.parse_file(Path("nonexistent.md"))
# Invalid file encoding
test_file = test_config.home / "binary.md"
test_file = project_config.home / "binary.md"
with open(test_file, "wb") as f:
f.write(b"\x80\x81") # Invalid UTF-8
with pytest.raises(UnicodeDecodeError):
@@ -143,7 +143,7 @@ async def test_error_handling(test_config, entity_parser):
@pytest.mark.asyncio
async def test_parse_file_without_section_headers(test_config, entity_parser):
async def test_parse_file_without_section_headers(project_config, entity_parser):
"""Test parsing a minimal valid entity file."""
content = dedent("""
---
@@ -163,7 +163,7 @@ async def test_parse_file_without_section_headers(test_config, entity_parser):
- references [[Other Entity]]
""")
test_file = test_config.home / "minimal.md"
test_file = project_config.home / "minimal.md"
test_file.write_text(content)
entity = await entity_parser.parse_file(test_file)
@@ -218,7 +218,7 @@ def test_parse_empty_content():
@pytest.mark.asyncio
async def test_parse_file_with_absolute_path(test_config, entity_parser):
async def test_parse_file_with_absolute_path(project_config, entity_parser):
"""Test parsing a file with an absolute path."""
content = dedent("""
---
@@ -232,7 +232,7 @@ async def test_parse_file_with_absolute_path(test_config, entity_parser):
""")
# Create a test file in the project directory
test_file = test_config.home / "absolute_path_test.md"
test_file = project_config.home / "absolute_path_test.md"
test_file.write_text(content)
# Get the absolute path to the test file
+7 -5
View File
@@ -13,22 +13,24 @@ from basic_memory.deps import get_project_config, get_engine_factory
from basic_memory.services.search_service import SearchService
from basic_memory.mcp.server import mcp as mcp_server
from basic_memory.config import app_config as basic_memory_app_config # noqa: F401
@pytest.fixture
@pytest.fixture(scope="function")
def mcp() -> FastMCP:
return mcp_server
@pytest.fixture
def app(app_config, test_config, engine_factory, monkeypatch) -> FastAPI:
@pytest.fixture(scope="function")
def app(app_config, project_config, engine_factory, project_session, config_manager) -> FastAPI:
"""Create test FastAPI application."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_project_config] = lambda: project_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="function")
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
@@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock
import pytest
from httpx import Response
from basic_memory.mcp.tools.project_info import project_info
from basic_memory.mcp.resources.project_info import project_info
from basic_memory.schemas import (
ProjectInfoResponse,
)
@@ -94,7 +94,7 @@ async def test_project_info_tool():
# Mock the call_get function
with patch(
"basic_memory.mcp.tools.project_info.call_get", return_value=mock_response
"basic_memory.mcp.resources.project_info.call_get", return_value=mock_response
) as mock_call_get:
# Call the function
result = await project_info()
@@ -133,7 +133,9 @@ async def test_project_info_tool():
async def test_project_info_error_handling():
"""Test that the project_info tool handles errors gracefully."""
# Mock call_get to raise an exception
with patch("basic_memory.mcp.tools.project_info.call_get", side_effect=Exception("Test error")):
with patch(
"basic_memory.mcp.resources.project_info.call_get", side_effect=Exception("Test error")
):
# Verify that the exception propagates
with pytest.raises(Exception) as excinfo:
await project_info()
+11 -11
View File
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools import canvas
@pytest.mark.asyncio
async def test_create_canvas(app, test_config):
async def test_create_canvas(app, project_config):
"""Test creating a new canvas file.
Should:
@@ -42,7 +42,7 @@ async def test_create_canvas(app, test_config):
assert "The canvas is ready to open in Obsidian" in result
# Verify file was created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
file_path = Path(project_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Verify content is correct
@@ -52,7 +52,7 @@ async def test_create_canvas(app, test_config):
@pytest.mark.asyncio
async def test_create_canvas_with_extension(app, test_config):
async def test_create_canvas_with_extension(app, project_config):
"""Test creating a canvas file with .canvas extension already in the title."""
# Test data
nodes = [
@@ -77,7 +77,7 @@ async def test_create_canvas_with_extension(app, test_config):
assert "Created: visualizations/extension-test.canvas" in result
# Verify file exists with correct name (shouldn't have double extension)
file_path = Path(test_config.home) / folder / title
file_path = Path(project_config.home) / folder / title
assert file_path.exists()
# Verify content
@@ -86,7 +86,7 @@ async def test_create_canvas_with_extension(app, test_config):
@pytest.mark.asyncio
async def test_update_existing_canvas(app, test_config):
async def test_update_existing_canvas(app, project_config):
"""Test updating an existing canvas file."""
# First create a canvas
nodes = [
@@ -108,7 +108,7 @@ async def test_update_existing_canvas(app, test_config):
await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify file exists
file_path = Path(test_config.home) / folder / f"{title}.canvas"
file_path = Path(project_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Now update with new content
@@ -140,7 +140,7 @@ async def test_update_existing_canvas(app, test_config):
@pytest.mark.asyncio
async def test_create_canvas_with_nested_folders(app, test_config):
async def test_create_canvas_with_nested_folders(app, project_config):
"""Test creating a canvas in nested folders that don't exist yet."""
# Test data
nodes = [
@@ -165,13 +165,13 @@ async def test_create_canvas_with_nested_folders(app, test_config):
assert "Created: visualizations/nested/folders/nested-test.canvas" in result
# Verify folders and file were created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
file_path = Path(project_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
assert file_path.parent.exists()
@pytest.mark.asyncio
async def test_create_canvas_complex_content(app, test_config):
async def test_create_canvas_complex_content(app, project_config):
"""Test creating a canvas with complex content structures."""
# Test data - more complex structure with all node types
nodes = [
@@ -237,7 +237,7 @@ async def test_create_canvas_complex_content(app, test_config):
folder = "visualizations"
# Create a test file that we're referencing
test_file_path = Path(test_config.home) / "test/test-file.md"
test_file_path = Path(project_config.home) / "test/test-file.md"
test_file_path.parent.mkdir(parents=True, exist_ok=True)
test_file_path.write_text("# Test File\nThis is referenced by the canvas")
@@ -248,7 +248,7 @@ async def test_create_canvas_complex_content(app, test_config):
assert "Created: visualizations/complex-test.canvas" in result
# Verify file was created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
file_path = Path(project_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Verify content is correct with all complex structures
+359
View File
@@ -0,0 +1,359 @@
"""Tests for the edit_note MCP tool."""
import pytest
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.write_note import write_note
@pytest.mark.asyncio
async def test_edit_note_append_operation(client):
"""Test appending content to an existing note."""
# Create initial note
await write_note(
title="Test Note",
folder="test",
content="# Test Note\nOriginal content here.",
)
# Append content
result = await edit_note(
identifier="test/test-note",
operation="append",
content="\n## New Section\nAppended content here.",
)
assert isinstance(result, str)
assert "Edited note (append)" in result
assert "file_path: test/Test Note.md" in result
assert "permalink: test/test-note" in result
assert "Added 3 lines to end of note" in result
@pytest.mark.asyncio
async def test_edit_note_prepend_operation(client):
"""Test prepending content to an existing note."""
# Create initial note
await write_note(
title="Meeting Notes",
folder="meetings",
content="# Meeting Notes\nExisting content.",
)
# Prepend content
result = await edit_note(
identifier="meetings/meeting-notes",
operation="prepend",
content="## 2025-05-25 Update\nNew meeting notes.\n",
)
assert isinstance(result, str)
assert "Edited note (prepend)" in result
assert "file_path: meetings/Meeting Notes.md" in result
assert "permalink: meetings/meeting-notes" in result
assert "Added 3 lines to beginning of note" in result
@pytest.mark.asyncio
async def test_edit_note_find_replace_operation(client):
"""Test find and replace operation."""
# Create initial note with version info
await write_note(
title="Config Document",
folder="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
# Replace version - expecting 2 replacements
result = await edit_note(
identifier="config/config-document",
operation="find_replace",
content="v0.13.0",
find_text="v0.12.0",
expected_replacements=2,
)
assert isinstance(result, str)
assert "Edited note (find_replace)" in result
assert "file_path: config/Config Document.md" in result
assert "operation: Find and replace operation completed" in result
@pytest.mark.asyncio
async def test_edit_note_replace_section_operation(client):
"""Test replacing content under a specific section."""
# Create initial note with sections
await write_note(
title="API Specification",
folder="specs",
content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.",
)
# Replace implementation section
result = await edit_note(
identifier="specs/api-specification",
operation="replace_section",
content="New implementation approach using FastAPI.\nImproved error handling.\n",
section="## Implementation",
)
assert isinstance(result, str)
assert "Edited note (replace_section)" in result
assert "file_path: specs/API Specification.md" in result
assert "Replaced content under section '## Implementation'" in result
@pytest.mark.asyncio
async def test_edit_note_nonexistent_note(client):
"""Test editing a note that doesn't exist - should return helpful guidance."""
result = await edit_note(
identifier="nonexistent/note", operation="append", content="Some content"
)
assert isinstance(result, str)
assert "# Edit Failed" in result
assert "search_notes" in result # Should suggest searching
assert "read_note" in result # Should suggest reading to verify
@pytest.mark.asyncio
async def test_edit_note_invalid_operation(client):
"""Test using an invalid operation."""
# Create a note first
await write_note(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(identifier="test/test-note", operation="invalid_op", content="Some content")
assert "Invalid operation 'invalid_op'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_edit_note_find_replace_missing_find_text(client):
"""Test find_replace operation without find_text parameter."""
# Create a note first
await write_note(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(
identifier="test/test-note", operation="find_replace", content="replacement"
)
assert "find_text parameter is required for find_replace operation" in str(exc_info.value)
@pytest.mark.asyncio
async def test_edit_note_replace_section_missing_section(client):
"""Test replace_section operation without section parameter."""
# Create a note first
await write_note(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(
identifier="test/test-note", operation="replace_section", content="new content"
)
assert "section parameter is required for replace_section operation" in str(exc_info.value)
@pytest.mark.asyncio
async def test_edit_note_replace_section_nonexistent_section(client):
"""Test replacing a section that doesn't exist - should append it."""
# Create initial note without the target section
await write_note(
title="Document",
folder="docs",
content="# Document\n\n## Existing Section\nSome content here.",
)
# Try to replace non-existent section
result = await edit_note(
identifier="docs/document",
operation="replace_section",
content="New section content here.\n",
section="## New Section",
)
assert isinstance(result, str)
assert "Edited note (replace_section)" in result
assert "file_path: docs/Document.md" in result
# Should succeed - the section gets appended if it doesn't exist
@pytest.mark.asyncio
async def test_edit_note_with_observations_and_relations(client):
"""Test editing a note that contains observations and relations."""
# Create note with semantic content
await write_note(
title="Feature Spec",
folder="features",
content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.",
)
# Append more semantic content
result = await edit_note(
identifier="features/feature-spec",
operation="append",
content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]",
)
assert isinstance(result, str)
assert "Edited note (append)" in result
assert "## Observations" in result
assert "## Relations" in result
@pytest.mark.asyncio
async def test_edit_note_identifier_variations(client):
"""Test that various identifier formats work."""
# Create a note
await write_note(
title="Test Document",
folder="docs",
content="# Test Document\nOriginal content.",
)
# Test different identifier formats
identifiers_to_test = [
"docs/test-document", # permalink
"Test Document", # title
"docs/Test Document", # folder/title
]
for identifier in identifiers_to_test:
result = await edit_note(
identifier=identifier, operation="append", content=f"\n## Update via {identifier}"
)
assert isinstance(result, str)
assert "Edited note (append)" in result
assert "file_path: docs/Test Document.md" in result
@pytest.mark.asyncio
async def test_edit_note_find_replace_no_matches(client):
"""Test find_replace when the find_text doesn't exist - should return error."""
# Create initial note
await write_note(
title="Test Note",
folder="test",
content="# Test Note\nSome content here.",
)
# Try to replace text that doesn't exist - should fail with default expected_replacements=1
result = await edit_note(
identifier="test/test-note",
operation="find_replace",
content="replacement",
find_text="nonexistent_text",
)
assert isinstance(result, str)
assert "# Edit Failed - Text Not Found" in result
assert "read_note" in result # Should suggest reading the note first
assert "Alternative approaches" in result # Should suggest alternatives
@pytest.mark.asyncio
async def test_edit_note_empty_content_operations(client):
"""Test operations with empty content."""
# Create initial note
await write_note(
title="Test Note",
folder="test",
content="# Test Note\nOriginal content.",
)
# Test append with empty content
result = await edit_note(identifier="test/test-note", operation="append", content="")
assert isinstance(result, str)
assert "Edited note (append)" in result
# Should still work, just adding empty content
@pytest.mark.asyncio
async def test_edit_note_find_replace_wrong_count(client):
"""Test find_replace when replacement count doesn't match expected."""
# Create initial note with version info
await write_note(
title="Config Document",
folder="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
# Try to replace expecting 1 occurrence, but there are actually 2
result = await edit_note(
identifier="config/config-document",
operation="find_replace",
content="v0.13.0",
find_text="v0.12.0",
expected_replacements=1, # Wrong! There are actually 2 occurrences
)
assert isinstance(result, str)
assert "# Edit Failed - Wrong Replacement Count" in result
assert "Expected 1 occurrences" in result
assert "but found 2" in result
assert "Update expected_replacements" in result # Should suggest the fix
assert "expected_replacements=2" in result # Should suggest the exact fix
@pytest.mark.asyncio
async def test_edit_note_replace_section_multiple_sections(client):
"""Test replace_section with multiple sections having same header - should return helpful error."""
# Create note with duplicate section headers
await write_note(
title="Sample Note",
folder="docs",
content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance",
)
# Try to replace section when multiple exist
result = await edit_note(
identifier="docs/sample-note",
operation="replace_section",
content="New content",
section="## Section 1",
)
assert isinstance(result, str)
assert "# Edit Failed - Duplicate Section Headers" in result
assert "Multiple sections found" in result
assert "read_note" in result # Should suggest reading the note first
assert "Make headers unique" in result # Should suggest making headers unique
@pytest.mark.asyncio
async def test_edit_note_find_replace_empty_find_text(client):
"""Test find_replace with empty/whitespace find_text - should return helpful error."""
# Create initial note
await write_note(
title="Test Note",
folder="test",
content="# Test Note\nSome content here.",
)
# Try with whitespace-only find_text - this should be caught by service validation
result = await edit_note(
identifier="test/test-note",
operation="find_replace",
content="replacement",
find_text=" ", # whitespace only
)
assert isinstance(result, str)
assert "# Edit Failed" in result
# Should contain helpful guidance about the error
+212
View File
@@ -0,0 +1,212 @@
"""Tests for the list_directory MCP tool."""
import pytest
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.write_note import write_note
@pytest.mark.asyncio
async def test_list_directory_empty(client):
"""Test listing directory when no entities exist."""
result = await list_directory()
assert isinstance(result, str)
assert "No files found in directory '/'" in result
@pytest.mark.asyncio
async def test_list_directory_with_test_graph(client, test_graph):
"""Test listing directory with test_graph fixture."""
# test_graph provides:
# /test/Connected Entity 1.md
# /test/Connected Entity 2.md
# /test/Deep Entity.md
# /test/Deeper Entity.md
# /test/Root.md
# List root directory
result = await list_directory()
assert isinstance(result, str)
assert "Contents of '/' (depth 1):" in result
assert "📁 test" in result
assert "Total: 1 items (1 directory)" in result
@pytest.mark.asyncio
async def test_list_directory_specific_path(client, test_graph):
"""Test listing specific directory path."""
# List the test directory
result = await list_directory(dir_name="/test")
assert isinstance(result, str)
assert "Contents of '/test' (depth 1):" in result
assert "📄 Connected Entity 1.md" in result
assert "📄 Connected Entity 2.md" in result
assert "📄 Deep Entity.md" in result
assert "📄 Deeper Entity.md" in result
assert "📄 Root.md" in result
assert "Total: 5 items (5 files)" in result
@pytest.mark.asyncio
async def test_list_directory_with_glob_filter(client, test_graph):
"""Test listing directory with glob filtering."""
# Filter for files containing "Connected"
result = await list_directory(dir_name="/test", file_name_glob="*Connected*")
assert isinstance(result, str)
assert "Files in '/test' matching '*Connected*' (depth 1):" in result
assert "📄 Connected Entity 1.md" in result
assert "📄 Connected Entity 2.md" in result
# Should not contain other files
assert "Deep Entity.md" not in result
assert "Deeper Entity.md" not in result
assert "Root.md" not in result
assert "Total: 2 items (2 files)" in result
@pytest.mark.asyncio
async def test_list_directory_with_markdown_filter(client, test_graph):
"""Test listing directory with markdown file filter."""
result = await list_directory(dir_name="/test", file_name_glob="*.md")
assert isinstance(result, str)
assert "Files in '/test' matching '*.md' (depth 1):" in result
# All files in test_graph are markdown files
assert "📄 Connected Entity 1.md" in result
assert "📄 Connected Entity 2.md" in result
assert "📄 Deep Entity.md" in result
assert "📄 Deeper Entity.md" in result
assert "📄 Root.md" in result
assert "Total: 5 items (5 files)" in result
@pytest.mark.asyncio
async def test_list_directory_with_depth_control(client, test_graph):
"""Test listing directory with depth control."""
# Depth 1: should return only the test directory
result_depth_1 = await list_directory(dir_name="/", depth=1)
assert isinstance(result_depth_1, str)
assert "Contents of '/' (depth 1):" in result_depth_1
assert "📁 test" in result_depth_1
assert "Total: 1 items (1 directory)" in result_depth_1
# Depth 2: should return directory + its files
result_depth_2 = await list_directory(dir_name="/", depth=2)
assert isinstance(result_depth_2, str)
assert "Contents of '/' (depth 2):" in result_depth_2
assert "📁 test" in result_depth_2
assert "📄 Connected Entity 1.md" in result_depth_2
assert "📄 Connected Entity 2.md" in result_depth_2
assert "📄 Deep Entity.md" in result_depth_2
assert "📄 Deeper Entity.md" in result_depth_2
assert "📄 Root.md" in result_depth_2
assert "Total: 6 items (1 directory, 5 files)" in result_depth_2
@pytest.mark.asyncio
async def test_list_directory_nonexistent_path(client, test_graph):
"""Test listing nonexistent directory."""
result = await list_directory(dir_name="/nonexistent")
assert isinstance(result, str)
assert "No files found in directory '/nonexistent'" in result
@pytest.mark.asyncio
async def test_list_directory_glob_no_matches(client, test_graph):
"""Test listing directory with glob that matches nothing."""
result = await list_directory(dir_name="/test", file_name_glob="*.xyz")
assert isinstance(result, str)
assert "No files found in directory '/test' matching '*.xyz'" in result
@pytest.mark.asyncio
async def test_list_directory_with_created_notes(client):
"""Test listing directory with dynamically created notes."""
# Create some test notes
await write_note(
title="Project Planning",
folder="projects",
content="# Project Planning\nThis is about planning projects.",
tags=["planning", "project"],
)
await write_note(
title="Meeting Notes",
folder="projects",
content="# Meeting Notes\nNotes from the meeting.",
tags=["meeting", "notes"],
)
await write_note(
title="Research Document",
folder="research",
content="# Research\nSome research findings.",
tags=["research"],
)
# List root directory
result_root = await list_directory()
assert isinstance(result_root, str)
assert "Contents of '/' (depth 1):" in result_root
assert "📁 projects" in result_root
assert "📁 research" in result_root
assert "Total: 2 items (2 directories)" in result_root
# List projects directory
result_projects = await list_directory(dir_name="/projects")
assert isinstance(result_projects, str)
assert "Contents of '/projects' (depth 1):" in result_projects
assert "📄 Project Planning.md" in result_projects
assert "📄 Meeting Notes.md" in result_projects
assert "Total: 2 items (2 files)" in result_projects
# Test glob filter for "Meeting"
result_meeting = await list_directory(dir_name="/projects", file_name_glob="*Meeting*")
assert isinstance(result_meeting, str)
assert "Files in '/projects' matching '*Meeting*' (depth 1):" in result_meeting
assert "📄 Meeting Notes.md" in result_meeting
assert "Project Planning.md" not in result_meeting
assert "Total: 1 items (1 file)" in result_meeting
@pytest.mark.asyncio
async def test_list_directory_path_normalization(client, test_graph):
"""Test that various path formats work correctly."""
# Test various equivalent path formats
paths_to_test = ["/test", "test", "/test/", "test/"]
for path in paths_to_test:
result = await list_directory(dir_name=path)
# All should return the same number of items
assert "Total: 5 items (5 files)" in result
assert "📄 Connected Entity 1.md" in result
@pytest.mark.asyncio
async def test_list_directory_shows_file_metadata(client, test_graph):
"""Test that file metadata is displayed correctly."""
result = await list_directory(dir_name="/test")
assert isinstance(result, str)
# Should show file names
assert "📄 Connected Entity 1.md" in result
assert "📄 Connected Entity 2.md" in result
# Should show directory paths
assert "test/Connected Entity 1.md" in result
assert "test/Connected Entity 2.md" in result
# Files should be listed after directories (but no directories in this case)
lines = result.split("\n")
file_lines = [line for line in lines if "📄" in line]
assert len(file_lines) == 5 # All 5 files from test_graph
+439
View File
@@ -0,0 +1,439 @@
"""Tests for the move_note MCP tool."""
import pytest
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.read_note import read_note
@pytest.mark.asyncio
async def test_move_note_success(app, client):
"""Test successfully moving a note to a new location."""
# Create initial note
await write_note(
title="Test Note",
folder="source",
content="# Test Note\nOriginal content here.",
)
# Move note
result = await move_note(
identifier="source/test-note",
destination_path="target/MovedNote.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
assert "source/test-note" in result
assert "target/MovedNote.md" in result
# Verify original location no longer exists
try:
await read_note("source/test-note")
assert False, "Original note should not exist after move"
except Exception:
pass # Expected - note should not exist at original location
# Verify note exists at new location with same content
content = await read_note("target/moved-note")
assert "# Test Note" in content
assert "Original content here" in content
assert "permalink: target/moved-note" in content
@pytest.mark.asyncio
async def test_move_note_with_folder_creation(client):
"""Test moving note creates necessary folders."""
# Create initial note
await write_note(
title="Deep Note",
folder="",
content="# Deep Note\nContent in root folder.",
)
# Move to deeply nested path
result = await move_note(
identifier="deep-note",
destination_path="deeply/nested/folder/DeepNote.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("deeply/nested/folder/deep-note")
assert "# Deep Note" in content
assert "Content in root folder" in content
@pytest.mark.asyncio
async def test_move_note_with_observations_and_relations(client):
"""Test moving note preserves observations and relations."""
# Create note with complex semantic content
await write_note(
title="Complex Entity",
folder="source",
content="""# Complex Entity
## Observations
- [note] Important observation #tag1
- [feature] Key feature #feature
## Relations
- relation to [[SomeOtherEntity]]
- depends on [[Dependency]]
Some additional content.
""",
)
# Move note
result = await move_note(
identifier="source/complex-entity",
destination_path="target/MovedComplex.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify moved note preserves all content
content = await read_note("target/moved-complex")
assert "Important observation #tag1" in content
assert "Key feature #feature" in content
assert "[[SomeOtherEntity]]" in content
assert "[[Dependency]]" in content
assert "Some additional content" in content
@pytest.mark.asyncio
async def test_move_note_by_title(client):
"""Test moving note using title as identifier."""
# Create note with unique title
await write_note(
title="UniqueTestTitle",
folder="source",
content="# UniqueTestTitle\nTest content.",
)
# Move using title as identifier
result = await move_note(
identifier="UniqueTestTitle",
destination_path="target/MovedByTitle.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("target/moved-by-title")
assert "# UniqueTestTitle" in content
assert "Test content" in content
@pytest.mark.asyncio
async def test_move_note_by_file_path(client):
"""Test moving note using file path as identifier."""
# Create initial note
await write_note(
title="PathTest",
folder="source",
content="# PathTest\nContent for path test.",
)
# Move using file path as identifier
result = await move_note(
identifier="source/PathTest.md",
destination_path="target/MovedByPath.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("target/moved-by-path")
assert "# PathTest" in content
assert "Content for path test" in content
@pytest.mark.asyncio
async def test_move_note_nonexistent_note(client):
"""Test moving a note that doesn't exist."""
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="nonexistent/note",
destination_path="target/SomeFile.md",
)
# Should raise an exception from the API with friendly error message
error_msg = str(exc_info.value)
assert "Entity not found" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
@pytest.mark.asyncio
async def test_move_note_invalid_destination_path(client):
"""Test moving note with invalid destination path."""
# Create initial note
await write_note(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test absolute path (should be rejected by validation)
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="source/test-note",
destination_path="/absolute/path.md",
)
# Should raise validation error (422 gets wrapped as client error)
error_msg = str(exc_info.value)
assert (
"Client error (422)" in error_msg
or "could not be completed" in error_msg
or "destination_path must be relative" in error_msg
)
@pytest.mark.asyncio
async def test_move_note_destination_exists(client):
"""Test moving note to existing destination."""
# Create source note
await write_note(
title="SourceNote",
folder="source",
content="# SourceNote\nSource content.",
)
# Create destination note
await write_note(
title="DestinationNote",
folder="target",
content="# DestinationNote\nDestination content.",
)
# Try to move source to existing destination
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="source/source-note",
destination_path="target/DestinationNote.md",
)
# Should raise an exception (400 gets wrapped as malformed request)
error_msg = str(exc_info.value)
assert "Destination already exists" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
@pytest.mark.asyncio
async def test_move_note_same_location(client):
"""Test moving note to the same location."""
# Create initial note
await write_note(
title="SameLocationTest",
folder="test",
content="# SameLocationTest\nContent here.",
)
# Try to move to same location
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="test/same-location-test",
destination_path="test/SameLocationTest.md",
)
# Should raise an exception (400 gets wrapped as malformed request)
error_msg = str(exc_info.value)
assert "Destination already exists" in error_msg or "same location" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
@pytest.mark.asyncio
async def test_move_note_rename_only(client):
"""Test moving note within same folder (rename operation)."""
# Create initial note
await write_note(
title="OriginalName",
folder="test",
content="# OriginalName\nContent to rename.",
)
# Rename within same folder
result = await move_note(
identifier="test/original-name",
destination_path="test/NewName.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify original is gone and new exists
try:
await read_note("test/original-name")
assert False, "Original note should not exist after rename"
except Exception:
pass # Expected
# Verify new name exists with same content
content = await read_note("test/new-name")
assert "# OriginalName" in content # Title in content remains same
assert "Content to rename" in content
assert "permalink: test/new-name" in content
@pytest.mark.asyncio
async def test_move_note_complex_filename(client):
"""Test moving note with spaces in filename."""
# Create note with spaces in name
await write_note(
title="Meeting Notes 2025",
folder="meetings",
content="# Meeting Notes 2025\nMeeting content with dates.",
)
# Move to new location
result = await move_note(
identifier="meetings/meeting-notes-2025",
destination_path="archive/2025/meetings/Meeting Notes 2025.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify note exists at new location with correct content
content = await read_note("archive/2025/meetings/meeting-notes-2025")
assert "# Meeting Notes 2025" in content
assert "Meeting content with dates" in content
@pytest.mark.asyncio
async def test_move_note_with_tags(client):
"""Test moving note with tags preserves tags."""
# Create note with tags
await write_note(
title="Tagged Note",
folder="source",
content="# Tagged Note\nContent with tags.",
tags=["important", "work", "project"],
)
# Move note
result = await move_note(
identifier="source/tagged-note",
destination_path="target/MovedTaggedNote.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify tags are preserved in correct YAML format
content = await read_note("target/moved-tagged-note")
assert "- important" in content
assert "- work" in content
assert "- project" in content
@pytest.mark.asyncio
async def test_move_note_empty_string_destination(client):
"""Test moving note with empty destination path."""
# Create initial note
await write_note(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test empty destination path
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="source/test-note",
destination_path="",
)
# Should raise validation error (422 gets wrapped as client error)
error_msg = str(exc_info.value)
assert (
"String should have at least 1 character" in error_msg
or "cannot be empty" in error_msg
or "Client error (422)" in error_msg
or "could not be completed" in error_msg
or "destination_path cannot be empty" in error_msg
)
@pytest.mark.asyncio
async def test_move_note_parent_directory_path(client):
"""Test moving note with parent directory in destination path."""
# Create initial note
await write_note(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test parent directory path
with pytest.raises(Exception) as exc_info:
await move_note(
identifier="source/test-note",
destination_path="../parent/file.md",
)
# Should raise validation error (422 gets wrapped as client error)
error_msg = str(exc_info.value)
assert (
"Client error (422)" in error_msg
or "could not be completed" in error_msg
or "cannot contain '..' path components" in error_msg
)
@pytest.mark.asyncio
async def test_move_note_identifier_variations(client):
"""Test that various identifier formats work for moving."""
# Create a note to test different identifier formats
await write_note(
title="Test Document",
folder="docs",
content="# Test Document\nContent for testing identifiers.",
)
# Test with permalink identifier
result = await move_note(
identifier="docs/test-document",
destination_path="moved/TestDocument.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify it moved correctly
content = await read_note("moved/test-document")
assert "# Test Document" in content
assert "Content for testing identifiers" in content
@pytest.mark.asyncio
async def test_move_note_preserves_frontmatter(client):
"""Test that moving preserves custom frontmatter."""
# Create note with custom frontmatter by first creating it normally
await write_note(
title="Custom Frontmatter Note",
folder="source",
content="# Custom Frontmatter Note\nContent with custom metadata.",
)
# Move the note
result = await move_note(
identifier="source/custom-frontmatter-note",
destination_path="target/MovedCustomNote.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
# Verify the moved note has proper frontmatter structure
content = await read_note("target/moved-custom-note")
assert "title: Custom Frontmatter Note" in content
assert "type: note" in content
assert "permalink: target/moved-custom-note" in content
assert "# Custom Frontmatter Note" in content
assert "Content with custom metadata" in content
+1
View File
@@ -17,6 +17,7 @@ def mock_response(monkeypatch):
def __init__(self, status_code=200):
self.status_code = status_code
self.is_success = status_code < 400
self.json = lambda: {}
def raise_for_status(self):
if self.status_code >= 400:
+113 -77
View File
@@ -24,18 +24,11 @@ async def test_write_note(app):
)
assert result
assert (
dedent("""
# Created note
file_path: test/Test Note.md
permalink: test/test-note
checksum: 159f2168
## Tags
- test, documentation
""").strip()
in result
)
assert "# Created note" in result
assert "file_path: test/Test Note.md" in result
assert "permalink: test/test-note" in result
assert "## Tags" in result
assert "- test, documentation" in result
# Try reading it back via permalink
content = await read_note("test/test-note")
@@ -46,8 +39,8 @@ async def test_write_note(app):
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
- test
- documentation
---
# Test
@@ -63,15 +56,9 @@ async def test_write_note_no_tags(app):
result = await write_note(title="Simple Note", folder="test", content="Just some text")
assert result
assert (
dedent("""
# Created note
file_path: test/Simple Note.md
permalink: test/simple-note
checksum: 9a1ff079
""").strip()
in result
)
assert "# Created note" in result
assert "file_path: test/Simple Note.md" in result
assert "permalink: test/simple-note" in result
# Should be able to read it back
content = await read_note("test/simple-note")
assert (
@@ -106,18 +93,11 @@ async def test_write_note_update_existing(app):
)
assert result # Got a valid permalink
assert (
dedent("""
# Created note
file_path: test/Test Note.md
permalink: test/test-note
checksum: 159f2168
## Tags
- test, documentation
""").strip()
in result
)
assert "# Created note" in result
assert "file_path: test/Test Note.md" in result
assert "permalink: test/test-note" in result
assert "## Tags" in result
assert "- test, documentation" in result
result = await write_note(
title="Test Note",
@@ -125,18 +105,11 @@ async def test_write_note_update_existing(app):
content="# Test\nThis is an updated note",
tags=["test", "documentation"],
)
assert (
dedent("""
# Updated note
file_path: test/Test Note.md
permalink: test/test-note
checksum: a8eb4d44
## Tags
- test, documentation
""").strip()
in result
)
assert "# Updated note" in result
assert "file_path: test/Test Note.md" in result
assert "permalink: test/test-note" in result
assert "## Tags" in result
assert "- test, documentation" in result
# Try reading it back
content = await read_note("test/test-note")
@@ -148,8 +121,8 @@ async def test_write_note_update_existing(app):
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
- test
- documentation
---
# Test
@@ -160,6 +133,82 @@ async def test_write_note_update_existing(app):
)
@pytest.mark.asyncio
async def test_issue_93_write_note_respects_custom_permalink_new_note(app):
"""Test that write_note respects custom permalinks in frontmatter for new notes (Issue #93)"""
# Create a note with custom permalink in frontmatter
content_with_custom_permalink = dedent("""
---
permalink: custom/my-desired-permalink
---
# My New Note
This note has a custom permalink specified in frontmatter.
- [note] Testing if custom permalink is respected
""").strip()
result = await write_note(
title="My New Note",
folder="notes",
content=content_with_custom_permalink,
)
# Verify the custom permalink is respected
assert "# Created note" in result
assert "file_path: notes/My New Note.md" in result
assert "permalink: custom/my-desired-permalink" in result
@pytest.mark.asyncio
async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
"""Test that write_note respects custom permalinks when updating existing notes (Issue #93)"""
# Step 1: Create initial note (auto-generated permalink)
result1 = await write_note(
title="Existing Note",
folder="test",
content="Initial content without custom permalink",
)
assert "# Created note" in result1
# Extract the auto-generated permalink
initial_permalink = None
for line in result1.split("\n"):
if line.startswith("permalink:"):
initial_permalink = line.split(":", 1)[1].strip()
break
assert initial_permalink is not None
# Step 2: Update with content that includes custom permalink in frontmatter
updated_content = dedent("""
---
permalink: custom/new-permalink
---
# Existing Note
Updated content with custom permalink in frontmatter.
- [note] Custom permalink should be respected on update
""").strip()
result2 = await write_note(
title="Existing Note",
folder="test",
content=updated_content,
)
# Verify the custom permalink is respected
assert "# Updated note" in result2
assert "permalink: custom/new-permalink" in result2
assert f"permalink: {initial_permalink}" not in result2
@pytest.mark.asyncio
async def test_delete_note_existing(app):
"""Test deleting a new note.
@@ -241,31 +290,18 @@ async def test_write_note_verbose(app):
tags=["test", "documentation"],
)
assert (
dedent("""
# Created note
file_path: test/Test Note.md
permalink: test/test-note
checksum: 06873a7a
## Observations
- note: 1
## Relations
- Resolved: 0
- Unresolved: 1
Unresolved relations will be retried on next sync.
## Tags
- test, documentation
""").strip()
in result
)
assert "# Created note" in result
assert "file_path: test/Test Note.md" in result
assert "permalink: test/test-note" in result
assert "## Observations" in result
assert "- note: 1" in result
assert "## Relations" in result
assert "## Tags" in result
assert "- test, documentation" in result
@pytest.mark.asyncio
async def test_write_note_preserves_custom_metadata(app, test_config):
async def test_write_note_preserves_custom_metadata(app, project_config):
"""Test that updating a note preserves custom metadata fields.
Reproduces issue #36 where custom frontmatter fields like Status
@@ -291,7 +327,7 @@ async def test_write_note_preserves_custom_metadata(app, test_config):
# We need to use a direct file update to add custom frontmatter
import frontmatter
file_path = test_config.home / "test" / "Custom Metadata Note.md"
file_path = project_config.home / "test" / "Custom Metadata Note.md"
post = frontmatter.load(file_path)
# Add custom frontmatter
@@ -327,9 +363,9 @@ async def test_write_note_preserves_custom_metadata(app, test_config):
# And new content should be there
assert "# Updated content" in content
# And tags should be updated
assert "'#test'" in content
assert "'#updated'" in content
# And tags should be updated (without # prefix)
assert "- test" in content
assert "- updated" in content
@pytest.mark.asyncio
@@ -366,8 +402,8 @@ async def test_write_note_preserves_content_frontmatter(app):
version: 1.0
author: name
tags:
- '#test'
- '#documentation'
- test
- documentation
---
# Test
+66
View File
@@ -11,6 +11,7 @@ from basic_memory.schemas import (
GetEntitiesRequest,
RelationResponse,
)
from basic_memory.schemas.request import EditEntityRequest
from basic_memory.schemas.base import to_snake_case, TimeFrame
@@ -211,3 +212,68 @@ def test_timeframe_validation(timeframe: str, expected_valid: bool):
else:
with pytest.raises(ValueError):
tf = TimeFrameModel.model_validate({"timeframe": timeframe})
def test_edit_entity_request_validation():
"""Test EditEntityRequest validation for operation-specific parameters."""
# Valid request - append operation
edit_request = EditEntityRequest.model_validate(
{"operation": "append", "content": "New content to append"}
)
assert edit_request.operation == "append"
assert edit_request.content == "New content to append"
# Valid request - find_replace operation with required find_text
edit_request = EditEntityRequest.model_validate(
{"operation": "find_replace", "content": "replacement text", "find_text": "text to find"}
)
assert edit_request.operation == "find_replace"
assert edit_request.find_text == "text to find"
# Valid request - replace_section operation with required section
edit_request = EditEntityRequest.model_validate(
{"operation": "replace_section", "content": "new section content", "section": "## Header"}
)
assert edit_request.operation == "replace_section"
assert edit_request.section == "## Header"
# Test that the validators return the value when validation passes
# This ensures the `return v` statements are covered
edit_request = EditEntityRequest.model_validate(
{
"operation": "find_replace",
"content": "replacement",
"find_text": "valid text",
"section": "## Valid Section",
}
)
assert edit_request.find_text == "valid text" # Covers line 88 (return v)
assert edit_request.section == "## Valid Section" # Covers line 80 (return v)
def test_edit_entity_request_find_replace_empty_find_text():
"""Test that find_replace operation requires non-empty find_text parameter."""
with pytest.raises(
ValueError, match="find_text parameter is required for find_replace operation"
):
EditEntityRequest.model_validate(
{
"operation": "find_replace",
"content": "replacement text",
"find_text": "", # Empty string triggers validation
}
)
def test_edit_entity_request_replace_section_empty_section():
"""Test that replace_section operation requires non-empty section parameter."""
with pytest.raises(
ValueError, match="section parameter is required for replace_section operation"
):
EditEntityRequest.model_validate(
{
"operation": "replace_section",
"content": "new content",
"section": "", # Empty string triggers validation
}
)
+128
View File
@@ -58,3 +58,131 @@ async def test_directory_tree(directory_service: DirectoryService, test_graph):
assert node_file.file_path == "test/Deeper Entity.md"
assert node_file.has_children is False
assert len(node_file.children) == 0
@pytest.mark.asyncio
async def test_list_directory_empty(directory_service: DirectoryService):
"""Test listing directory with no entities."""
result = await directory_service.list_directory()
assert result == []
@pytest.mark.asyncio
async def test_list_directory_root(directory_service: DirectoryService, test_graph):
"""Test listing root directory contents."""
result = await directory_service.list_directory(dir_name="/")
# Should return immediate children of root (the "test" directory)
assert len(result) == 1
assert result[0].name == "test"
assert result[0].type == "directory"
assert result[0].directory_path == "/test"
@pytest.mark.asyncio
async def test_list_directory_specific_path(directory_service: DirectoryService, test_graph):
"""Test listing specific directory contents."""
result = await directory_service.list_directory(dir_name="/test")
# Should return the 5 files in the test directory
assert len(result) == 5
file_names = {node.name for node in result}
expected_files = {
"Connected Entity 1.md",
"Connected Entity 2.md",
"Deep Entity.md",
"Deeper Entity.md",
"Root.md",
}
assert file_names == expected_files
# All should be files
for node in result:
assert node.type == "file"
@pytest.mark.asyncio
async def test_list_directory_nonexistent_path(directory_service: DirectoryService, test_graph):
"""Test listing nonexistent directory."""
result = await directory_service.list_directory(dir_name="/nonexistent")
assert result == []
@pytest.mark.asyncio
async def test_list_directory_with_glob_filter(directory_service: DirectoryService, test_graph):
"""Test listing directory with glob pattern filtering."""
# Filter for files containing "Connected"
result = await directory_service.list_directory(dir_name="/test", file_name_glob="*Connected*")
assert len(result) == 2
file_names = {node.name for node in result}
assert file_names == {"Connected Entity 1.md", "Connected Entity 2.md"}
@pytest.mark.asyncio
async def test_list_directory_with_markdown_filter(directory_service: DirectoryService, test_graph):
"""Test listing directory with markdown file filter."""
result = await directory_service.list_directory(dir_name="/test", file_name_glob="*.md")
# All files in test_graph are markdown files
assert len(result) == 5
@pytest.mark.asyncio
async def test_list_directory_with_specific_file_filter(
directory_service: DirectoryService, test_graph
):
"""Test listing directory with specific file pattern."""
result = await directory_service.list_directory(dir_name="/test", file_name_glob="Root.*")
assert len(result) == 1
assert result[0].name == "Root.md"
@pytest.mark.asyncio
async def test_list_directory_depth_control(directory_service: DirectoryService, test_graph):
"""Test listing directory with depth control."""
# Depth 1 should only return immediate children
result_depth_1 = await directory_service.list_directory(dir_name="/", depth=1)
assert len(result_depth_1) == 1 # Just the "test" directory
# Depth 2 should return directory + its contents
result_depth_2 = await directory_service.list_directory(dir_name="/", depth=2)
assert len(result_depth_2) == 6 # "test" directory + 5 files in it
@pytest.mark.asyncio
async def test_list_directory_path_normalization(directory_service: DirectoryService, test_graph):
"""Test that directory paths are normalized correctly."""
# Test various path formats that should all be equivalent
paths_to_test = ["/test", "test", "/test/", "test/"]
base_result = await directory_service.list_directory(dir_name="/test")
for path in paths_to_test:
result = await directory_service.list_directory(dir_name=path)
assert len(result) == len(base_result)
# Compare by name since the objects might be different instances
result_names = {node.name for node in result}
base_names = {node.name for node in base_result}
assert result_names == base_names
@pytest.mark.asyncio
async def test_list_directory_glob_no_matches(directory_service: DirectoryService, test_graph):
"""Test listing directory with glob that matches nothing."""
result = await directory_service.list_directory(
dir_name="/test", file_name_glob="*.nonexistent"
)
assert result == []
@pytest.mark.asyncio
async def test_list_directory_default_parameters(directory_service: DirectoryService, test_graph):
"""Test listing directory with default parameters."""
# Should default to root directory, depth 1, no glob filter
result = await directory_service.list_directory()
assert len(result) == 1
assert result[0].name == "test"
assert result[0].type == "directory"

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