Compare commits

...

23 Commits

Author SHA1 Message Date
Drew Cain 8f962fdd87 chore: update version to 0.18.0 for v0.18.0 release 2026-01-29 22:41:42 -06:00
Drew Cain fbb497f6dc docs: add CHANGELOG entry for v0.18.0 2026-01-29 22:41:14 -06:00
Drew Cain 0023e736ab feat: add context-aware wiki link resolution with source_path support (#527)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 19:11:41 -06:00
Paul Hernandez 0b2080114b feat: add directory support to move_note and delete_note tools (#518)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:52:10 -06:00
Paul Hernandez 8730067f3a feat: Feature/517 local mcp cloud mode (#522)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:51:49 -06:00
Drew Cain e14ba92631 fix: resolve MCP prompt rendering errors (#524)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:14:41 -06:00
phernandez 9d98892570 chore: update version to 0.17.9 for v0.17.9 release 2026-01-24 12:55:58 -06:00
phernandez 3be4495723 docs: add CHANGELOG entry for v0.17.9
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-24 12:55:27 -06:00
Paul Hernandez 17c0e0a29b fix: check config default_project only in local mode for remove_project (#523)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:53:44 -06:00
phernandez 7ebf16a95d chore: update version to 0.17.8 for v0.17.8 release 2026-01-24 11:43:44 -06:00
phernandez c05075f8d4 docs: add CHANGELOG entry for v0.17.8
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-24 11:43:21 -06:00
phernandez 4cef9281ca docs: add CHANGELOG entry for v0.17.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-24 11:39:41 -06:00
Paul Hernandez 6888effef2 fix: correct get_default_project() query to check for True instead of not NULL (#521)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:37:05 -06:00
Paul Hernandez 38616c345d fix: read default project from database in cloud mode (#520)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:44:04 -06:00
phernandez f3c1aa895c fix links in README.md to remove smithery badge
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:58:03 -06:00
phernandez d978aba09b fix links in README.md to remove glama.ai
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:56:33 -06:00
phernandez 2aaee734c9 fix links in README.md to point to basicmemory.com instead of basicmachines.co
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:53:18 -06:00
Paul Hernandez 369ad37b3d feat: add SPEC-29 Phase 3 bucket snapshot CLI commands (#476)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:55:01 -06:00
Drew Cain 4e5f701d22 Fix server.json runtimeArguments format
- Use proper Argument object format instead of plain strings
- Add .mcpregistry tokens to .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-01-19 12:23:29 -06:00
Drew Cain 9d9ea4d61c chore: update version to 0.17.7 for v0.17.7 release 2026-01-19 12:12:35 -06:00
Drew Cain 7a502e6474 feat: Add MCP registry publication files (#515)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:57:50 -06:00
Paul Hernandez c7835a9d5c fix: ensure external_id is set on entity creation (#512) (#513)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:14:48 -06:00
Paul Hernandez 85835ae533 chore: Remove OpenPanel telemetry (#514)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:24:27 -06:00
125 changed files with 5999 additions and 1846 deletions
+31 -1
View File
@@ -78,6 +78,34 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) then:
2. Verify formula version matches release
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
#### MCP Registry Publication
After PyPI release is published, update the MCP registry:
1. **Verify PyPI Release**
- Confirm package is live: https://pypi.org/project/basic-memory/<version>/
- The `server.json` version was auto-updated by `just release`
2. **Publish to MCP Registry**
```bash
cd /Users/drew/code/basic-memory
mcp-publisher publish
```
If not authenticated:
```bash
mcp-publisher login github
# Follow device authentication flow
mcp-publisher publish
```
3. **Verify Publication**
```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=basic-memory"
```
**Note:** The `mcp-publisher` CLI can be installed via Homebrew (`brew install mcp-publisher`) or from GitHub releases.
#### Website Updates
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
@@ -145,6 +173,7 @@ Before starting, verify:
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
🔌 MCP Registry: https://registry.modelcontextprotocol.io
🚀 GitHub Actions: Completed
Install with pip/uv:
@@ -162,8 +191,9 @@ Users can now upgrade:
- This creates production releases used by end users
- Must pass all quality gates before proceeding
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Version is automatically updated in `__init__.py` and `server.json`
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- MCP Registry is updated manually via `mcp-publisher publish`
- Supports multiple installation methods (uv, pip, Homebrew)
+1
View File
@@ -54,3 +54,4 @@ ENV/
claude-output
**/.claude/settings.local.json
.mcp.json
.mcpregistry_*
+77
View File
@@ -1,5 +1,82 @@
# CHANGELOG
## v0.18.0 (2026-01-28)
### Features
- **#527**: Add context-aware wiki link resolution with source_path support
([`0023e73`](https://github.com/basicmachines-co/basic-memory/commit/0023e73))
- Add `source_path` parameter to `resolve_link()` for context-aware resolution
- Relative path resolution: `[[nested/note]]` from `folder/file.md``folder/nested/note.md`
- Proximity-based resolution for duplicate titles (prefers notes in same folder)
- Strict mode to disable fuzzy search fallback for wiki links
- **#518**: Add directory support to move_note and delete_note tools
([`0b20801`](https://github.com/basicmachines-co/basic-memory/commit/0b20801))
- Add `is_directory` parameter to `move_note` and `delete_note` MCP tools
- New `POST /move-directory` and delete directory API endpoints
- Rename `folder``directory` parameter across codebase for consistency
- **#522**: Local MCP cloud mode routing
([`8730067`](https://github.com/basicmachines-co/basic-memory/commit/8730067))
- Add `--local` and `--cloud` CLI routing flags
- Local MCP server (`basic-memory mcp`) automatically uses local routing
- Enables simultaneous use of local Claude Desktop and cloud-based clients
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` for global override
### Bug Fixes
- **#524**: Fix MCP prompt rendering errors
([`e14ba92`](https://github.com/basicmachines-co/basic-memory/commit/e14ba92))
- Fix "Error rendering prompt recent_activity" error
- Change `TimeFrame` to `str` in prompt type annotations for FastMCP compatibility
## v0.17.9 (2026-01-24)
### Bug Fixes
- **#523**: Fix `remove_project()` checking stale config in cloud mode
([`17c0e0a`](https://github.com/basicmachines-co/basic-memory/commit/17c0e0a))
- In cloud mode, only check database `is_default` field (source of truth)
- Config file can become stale when users set default project via v2 API
## v0.17.8 (2026-01-24)
### Bug Fixes
- **#521**: Fix `get_default_project()` returning multiple results
([`6888eff`](https://github.com/basicmachines-co/basic-memory/commit/6888eff))
- Query incorrectly matched any project with non-NULL `is_default` (both True and False)
- Now correctly checks for `is_default=True` only
## v0.17.7 (2026-01-24)
### Features
- **#476**: Add SPEC-29 Phase 3 bucket snapshot CLI commands
([`369ad37`](https://github.com/basicmachines-co/basic-memory/commit/369ad37))
- New `basic-memory cloud snapshot` commands for managing cloud snapshots
- Commands: `create`, `list`, `delete`, `show`, `browse`
- **#515**: Add MCP registry publication files
([`7a502e6`](https://github.com/basicmachines-co/basic-memory/commit/7a502e6))
### Bug Fixes
- **#520**: Read default project from database in cloud mode
([`38616c3`](https://github.com/basicmachines-co/basic-memory/commit/38616c3))
- **#513**: Ensure external_id is set on entity creation
([`c7835a9`](https://github.com/basicmachines-co/basic-memory/commit/c7835a9))
### Internal
- **#514**: Remove OpenPanel telemetry
([`85835ae`](https://github.com/basicmachines-co/basic-memory/commit/85835ae))
- Update README links to point to basicmemory.com
([`2aaee73`](https://github.com/basicmachines-co/basic-memory/commit/2aaee73))
## v0.17.6 (2026-01-17)
### Bug Fixes
+26 -4
View File
@@ -240,19 +240,21 @@ See SPEC-16 for full context manager refactor details.
- Logout: `basic-memory cloud logout`
- Check cloud status: `basic-memory cloud status`
- Setup cloud sync: `basic-memory cloud setup`
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
**Content Management:**
- `write_note(title, content, folder, tags)` - Create/update markdown notes with semantic observations and relations
- `write_note(title, content, directory, tags)` - Create/update markdown notes with semantic observations and relations
- `read_note(identifier, page, page_size)` - Read notes by title, permalink, or memory:// URL with knowledge graph awareness
- `read_content(path)` - Read raw file content (text, images, binaries) without knowledge graph processing
- `view_note(identifier, page, page_size)` - View notes as formatted artifacts for better readability
- `edit_note(identifier, operation, content)` - Edit notes incrementally (append, prepend, find/replace, replace_section)
- `move_note(identifier, destination_path)` - Move notes to new locations, updating database and maintaining links
- `delete_note(identifier)` - Delete notes from the knowledge base
- `move_note(identifier, destination_path, is_directory)` - Move notes or directories to new locations, updating database and maintaining links
- `delete_note(identifier, is_directory)` - Delete notes or directories from the knowledge base
**Knowledge Graph Navigation:**
- `build_context(url, depth, timeframe)` - Navigate the knowledge graph via memory:// URLs for conversation continuity
@@ -268,7 +270,7 @@ See SPEC-16 for full context manager refactor details.
- `delete_project(project_name)` - Delete a project from configuration
**Visualization:**
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
- `canvas(nodes, edges, title, directory)` - Generate Obsidian canvas files for knowledge graph visualization
**ChatGPT-Compatible Tools:**
- `search(query)` - Search across knowledge base (OpenAI actions compatible)
@@ -308,6 +310,26 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
- Background relation resolution (non-blocking startup)
- API performance optimizations (SPEC-11)
**CLI Routing Flags:**
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
```bash
# Force local routing (ignore cloud mode)
basic-memory status --local
basic-memory project list --local
# Force cloud routing (when cloud mode is disabled)
basic-memory status --cloud
basic-memory project info my-project --cloud
```
Key behaviors:
- The local MCP server (`basic-memory mcp`) automatically uses local routing
- This allows simultaneous use of local Claude Desktop and cloud-based clients
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
## AI-Human Collaborative Development
Basic Memory emerged from and enables a new kind of development process that combines human and AI capabilities. Instead
+22 -56
View File
@@ -1,3 +1,4 @@
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![PyPI version](https://badge.fury.io/py/basic-memory.svg)](https://badge.fury.io/py/basic-memory)
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
@@ -5,7 +6,6 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
![](https://badge.mcpx.dev?type=server 'MCP Server')
![](https://badge.mcpx.dev?type=dev 'MCP Dev')
[![smithery badge](https://smithery.ai/badge/@basicmachines-co/basic-memory)](https://smithery.ai/server/@basicmachines-co/basic-memory)
## 🚀 Basic Memory Cloud is Live!
@@ -13,7 +13,7 @@
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
[Sign up now →](https://basicmemory.com/beta)
[Sign up now →](https://basicmemory.com)
with a 7 day free trial
@@ -23,8 +23,8 @@ Basic Memory lets you build persistent knowledge through natural conversations w
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
enable any compatible LLM to read and write to your local knowledge base.
- Website: https://basicmachines.co
- Documentation: https://memory.basicmachines.co
- Website: https://basicmemory.com
- Documentation: https://docs.basicmemory.com
## Pick up your conversation right where you left off
@@ -62,24 +62,6 @@ uv tool install basic-memory
You can view shared context via files in `~/basic-memory` (default directory location).
### Alternative Installation via Smithery
You can use [Smithery](https://smithery.ai/server/@basicmachines-co/basic-memory) to automatically configure Basic
Memory for Claude Desktop:
```bash
npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
```
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. The
Smithery server hosts the MCP server component, while your data remains stored locally as Markdown files.
### Glama.ai
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
<img width="380" height="200" src="https://glama.ai/mcp/servers/o90kttu9ym/badge" alt="basic-memory MCP server" />
</a>
## Why Basic Memory?
Most LLM interactions are ephemeral - you ask a question, get an answer, and everything is forgotten. Each conversation
@@ -375,6 +357,22 @@ basic-memory cloud check
basic-memory cloud mount
```
**Routing Flags** (for users with cloud subscriptions):
When cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
```bash
# Force local routing (useful for local MCP server while cloud mode is enabled)
basic-memory status --local
basic-memory project list --local
# Force cloud routing (when cloud mode is disabled but you want cloud access)
basic-memory status --cloud
basic-memory project info my-project --cloud
```
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
4. In Claude Desktop, the LLM can now use these tools:
**Content Management:**
@@ -425,7 +423,7 @@ canvas(nodes, edges, title, folder) - Generate knowledge visualizations
## Futher info
See the [Documentation](https://memory.basicmachines.co/) for more info, including:
See the [Documentation](https://docs.basicmemory.com) for more info, including:
- [Complete User Guide](https://docs.basicmemory.com/user-guide/)
- [CLI tools](https://docs.basicmemory.com/guides/cli-reference/)
@@ -451,6 +449,7 @@ Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The lo
|----------|---------|-------------|
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) |
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
### Examples
@@ -466,39 +465,6 @@ tail -f ~/.basic-memory/basic-memory.log
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
```
## Telemetry
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
**What we collect:**
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized - no file paths or personal data)
**What we NEVER collect:**
- Note content, file names, or paths
- Personal information
- IP addresses
**Opting out:**
```bash
# Disable telemetry
basic-memory telemetry disable
# Check status
basic-memory telemetry status
# Re-enable
basic-memory telemetry enable
```
Or set the environment variable:
```bash
export BASIC_MEMORY_TELEMETRY_ENABLED=false
```
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
## Development
### Running Tests
+1 -3
View File
@@ -8,9 +8,8 @@ To keep the default CI signal **stable and meaningful**, the default `pytest` co
- highly environment-dependent (OS/DB tuning)
- inherently interactive (CLI)
- background-task orchestration (watchers/sync runners)
- external analytics
### Whats excluded (and why)
### What's excluded (and why)
Coverage excludes are configured in `pyproject.toml` under `[tool.coverage.report].omit`.
@@ -19,7 +18,6 @@ Current exclusions include:
- `src/basic_memory/db.py`: platform/backend tuning paths (SQLite/Postgres/Windows), covered by integration tests and targeted runs.
- `src/basic_memory/services/initialization.py`: startup orchestration/background tasks; covered indirectly by app/MCP entrypoints.
- `src/basic_memory/sync/sync_service.py`: heavy filesystem↔DB integration; validated in integration suite (not enforced in unit coverage).
- `src/basic_memory/telemetry.py`: external analytics; exercised lightly but excluded from strict coverage gate.
### Recommended additional runs
+16 -5
View File
@@ -204,9 +204,14 @@ release version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} release"
# Create and push tag
@@ -221,9 +226,10 @@ release version:
echo "📦 GitHub Actions will build and publish to PyPI"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo ""
echo "📝 REMINDER: Update documentation sites after release is published:"
echo "📝 REMINDER: Post-release tasks:"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " 3. MCP Registry - Run: mcp-publisher publish"
echo " See: .claude/commands/release/release.md for detailed instructions"
# Create a beta release (e.g., just beta v0.13.2b1)
@@ -269,9 +275,14 @@ beta version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} beta release"
# Create and push tag
-2
View File
@@ -41,7 +41,6 @@ dependencies = [
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
"sniffio>=1.3.1",
"anyio>=4.10.0",
"httpx>=0.28.0",
@@ -143,6 +142,5 @@ omit = [
"*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
"*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
"*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
"*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
"*/services/migration_service.py", # Complex migration scenarios
]
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.basicmachines-co/basic-memory",
"description": "Local-first knowledge management with bi-directional LLM sync via Markdown files.",
"repository": {
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.18.0",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.18.0",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
{"type": "positional", "value": "mcp"}
],
"transport": {
"type": "stdio"
}
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.17.6"
__version__ = "0.18.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+12 -12
View File
@@ -27,13 +27,13 @@ router = APIRouter(prefix="/import", tags=["import"])
async def import_chatgpt(
importer: ChatGPTImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from ChatGPT JSON export.
Args:
file: The ChatGPT conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -42,20 +42,20 @@ async def import_chatgpt(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/conversations", response_model=ChatImportResult)
async def import_claude_conversations(
importer: ClaudeConversationsImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from Claude conversations.json export.
Args:
file: The Claude conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -64,20 +64,20 @@ async def import_claude_conversations(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/projects", response_model=ProjectImportResult)
async def import_claude_projects(
importer: ClaudeProjectsImporterDep,
file: UploadFile,
folder: str = Form("projects"),
directory: str = Form("projects"),
) -> ProjectImportResult:
"""Import projects from Claude projects.json export.
Args:
file: The Claude projects.json file.
base_folder: The base folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -86,20 +86,20 @@ async def import_claude_projects(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/memory-json", response_model=EntityImportResult)
async def import_memory_json(
importer: MemoryJsonImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> EntityImportResult:
"""Import entities and relations from a memory.json file.
Args:
file: The memory.json file.
destination_folder: Optional destination folder within the project.
directory: Optional destination directory within the project.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -116,7 +116,7 @@ async def import_memory_json(
json_data = json.loads(line)
file_data.append(json_data)
result = await importer.import_data(file_data, folder)
result = await importer.import_data(file_data, directory)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -29,7 +29,8 @@ from basic_memory.schemas import (
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest, MoveDirectoryRequest
from basic_memory.schemas.response import DirectoryMoveResult
from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(
@@ -231,6 +232,50 @@ async def move_entity(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/move-directory")
async def move_directory(
data: MoveDirectoryRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
project_config: ProjectConfigDep,
app_config: AppConfigDep,
search_service: SearchServiceDep,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
This endpoint moves all files within a source directory to a destination
directory, updating database records and optionally updating permalinks.
"""
logger.info(
f"API request: endpoint='move_directory', source='{data.source_directory}', destination='{data.destination_directory}'"
)
try:
# Move the directory using the service
result = await entity_service.move_directory(
source_directory=data.source_directory,
destination_directory=data.destination_directory,
project_config=project_config,
app_config=app_config,
)
# Reindex moved entities
for file_path in result.moved_files:
entity = await entity_service.link_resolver.resolve_link(file_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
logger.info(
f"API response: endpoint='move_directory', "
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
)
return result
except Exception as e:
logger.error(f"Error moving directory: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Read endpoints
+15 -3
View File
@@ -322,7 +322,14 @@ async def remove_project(
) # pragma: no cover
# Check if trying to delete the default project
if name == project_service.default_project:
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_project_name = db_default.name if db_default else config_default
if name == default_project_name:
available_projects = await project_service.list_projects()
other_projects = [p.name for p in available_projects if p.name != name]
detail = f"Cannot delete default project '{name}'. "
@@ -418,8 +425,13 @@ async def get_default_project(
Returns:
Response with project default information
"""
# Get the old default project
default_name = project_service.default_project
# Get the default project
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_name = db_default.name if db_default else config_default
default_project = await project_service.get_project(default_name)
if not default_project: # pragma: no cover
raise HTTPException( # pragma: no cover
@@ -1,6 +1,7 @@
"""Routes for getting entity content."""
import tempfile
import uuid
from pathlib import Path
from typing import Annotated, Union
@@ -218,7 +219,9 @@ async def write_resource(
status_code = 200
else:
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
@@ -32,14 +32,14 @@ async def import_chatgpt(
importer: ChatGPTImporterV2ExternalDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from ChatGPT JSON export.
Args:
project_id: Project external UUID from URL path
file: The ChatGPT conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
importer: ChatGPT importer instance.
Returns:
@@ -49,7 +49,7 @@ async def import_chatgpt(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/conversations", response_model=ChatImportResult)
@@ -57,14 +57,14 @@ async def import_claude_conversations(
importer: ClaudeConversationsImporterV2ExternalDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from Claude conversations.json export.
Args:
project_id: Project external UUID from URL path
file: The Claude conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
importer: Claude conversations importer instance.
Returns:
@@ -74,7 +74,7 @@ async def import_claude_conversations(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude conversations for project {project_id}")
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/projects", response_model=ProjectImportResult)
@@ -82,14 +82,14 @@ async def import_claude_projects(
importer: ClaudeProjectsImporterV2ExternalDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
folder: str = Form("projects"),
directory: str = Form("projects"),
) -> ProjectImportResult:
"""Import projects from Claude projects.json export.
Args:
project_id: Project external UUID from URL path
file: The Claude projects.json file.
folder: The base folder to place the files in.
directory: The base directory to place the files in.
importer: Claude projects importer instance.
Returns:
@@ -99,7 +99,7 @@ async def import_claude_projects(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude projects for project {project_id}")
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/memory-json", response_model=EntityImportResult)
@@ -107,14 +107,14 @@ async def import_memory_json(
importer: MemoryJsonImporterV2ExternalDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> EntityImportResult:
"""Import entities and relations from a memory.json file.
Args:
project_id: Project external UUID from URL path
file: The memory.json file.
folder: Optional destination folder within the project.
directory: Optional destination directory within the project.
importer: Memory JSON importer instance.
Returns:
@@ -132,7 +132,7 @@ async def import_memory_json(
json_data = json.loads(line)
file_data.append(json_data)
result = await importer.import_data(file_data, folder)
result = await importer.import_data(file_data, directory)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -147,13 +147,13 @@ async def import_memory_json(
return result
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
"""Helper function to import a file using an importer instance.
Args:
importer: The importer instance to use
file: The file to import
destination_folder: Destination folder for imported content
destination_directory: Destination directory for imported content
Returns:
Import result from the importer
@@ -164,7 +164,7 @@ async def import_file(importer: Importer, file: UploadFile, destination_folder:
try:
# Process file
json_data = json.load(file.file)
result = await importer.import_data(json_data, destination_folder)
result = await importer.import_data(json_data, destination_directory)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -31,7 +31,10 @@ from basic_memory.schemas.v2 import (
EntityResolveResponse,
EntityResponseV2,
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
)
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
@@ -100,8 +103,12 @@ async def resolve_identifier(
resolution_method = "external_id" if entity else "search"
# If not found by external_id, try other resolution methods
# Pass source_path for context-aware resolution (prefers notes closer to source)
# Pass strict to control fuzzy search fallback (default False allows fuzzy matching)
if not entity:
entity = await link_resolver.resolve_link(data.identifier)
entity = await link_resolver.resolve_link(
data.identifier, source_path=data.source_path, strict=data.strict
)
if entity:
# Determine resolution method
if entity.permalink == data.identifier:
@@ -423,3 +430,100 @@ async def move_entity(
except Exception as e:
logger.error(f"Error moving entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Move directory endpoint
@router.post("/move-directory", response_model=DirectoryMoveResult)
async def move_directory(
data: MoveDirectoryRequestV2,
background_tasks: BackgroundTasks,
project_id: ProjectExternalIdPathDep,
entity_service: EntityServiceV2ExternalDep,
project_config: ProjectConfigV2ExternalDep,
app_config: AppConfigDep,
search_service: SearchServiceV2ExternalDep,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
V2 API uses project external_id in the URL path for stable references.
Moves all files within a source directory to a destination directory,
updating database records and optionally updating permalinks.
Args:
project_id: Project external ID from URL path
data: Move request with source and destination directories
Returns:
DirectoryMoveResult with counts and details of moved files
"""
logger.info(
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
)
try:
# Move the directory using the service
result = await entity_service.move_directory(
source_directory=data.source_directory,
destination_directory=data.destination_directory,
project_config=project_config,
app_config=app_config,
)
# Reindex moved entities
for file_path in result.moved_files:
entity = await entity_service.link_resolver.resolve_link(file_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
logger.info(
f"API v2 response: move_directory "
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
)
return result
except Exception as e:
logger.error(f"Error moving directory: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Delete directory endpoint
@router.post("/delete-directory", response_model=DirectoryDeleteResult)
async def delete_directory(
data: DeleteDirectoryRequestV2,
project_id: ProjectExternalIdPathDep,
entity_service: EntityServiceV2ExternalDep,
) -> DirectoryDeleteResult:
"""Delete all entities in a directory.
V2 API uses project external_id in the URL path for stable references.
Deletes all files within a directory, updating database records and
removing files from the filesystem.
Args:
project_id: Project external ID from URL path
data: Delete request with directory path
Returns:
DirectoryDeleteResult with counts and details of deleted files
"""
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
try:
# Delete the directory using the service
result = await entity_service.delete_directory(
directory=data.directory,
)
logger.info(
f"API v2 response: delete_directory "
f"total={result.total_files}, success={result.successful_deletes}, failed={result.failed_deletes}"
)
return result
except Exception as e:
logger.error(f"Error deleting directory: {e}")
raise HTTPException(status_code=400, detail=str(e))
@@ -9,6 +9,7 @@ Key differences from v1:
- More RESTful: POST for create, PUT for update, GET for read
"""
import uuid
from pathlib import Path as PathLib
from fastapi import APIRouter, HTTPException, Response, Path
@@ -147,7 +148,9 @@ async def create_resource(
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
-8
View File
@@ -10,7 +10,6 @@ import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
def version_callback(value: bool) -> None:
@@ -47,13 +46,6 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Show telemetry notice and track CLI startup
# Skip for 'mcp' command - it handles its own telemetry in lifespan
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
show_notice_if_needed()
track_app_started("cli")
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
+1 -2
View File
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory."""
from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
from . import import_claude_projects, import_chatgpt, tool, project, format
__all__ = [
"status",
@@ -14,5 +14,4 @@ __all__ = [
"tool",
"project",
"format",
"telemetry",
]
@@ -1,6 +1,16 @@
"""Cloud commands package."""
from basic_memory.cli.app import cloud_app
# Import all commands to register them with typer
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
# Register snapshot sub-command group
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
cloud_app.add_typer(snapshot_app, name="snapshot")
# Register restore command (directly on cloud_app via decorator)
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
@@ -0,0 +1,159 @@
"""Restore CLI commands for Basic Memory Cloud.
SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots.
"""
import asyncio
import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
make_api_request,
)
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
from basic_memory.config import ConfigManager
console = Console()
@cloud_app.command("restore")
def restore(
path: str = typer.Argument(
...,
help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')",
),
snapshot_id: str = typer.Option(
...,
"--snapshot",
"-s",
help="ID of the snapshot to restore from",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation prompt",
),
) -> None:
"""Restore a file or folder from a snapshot.
This command restores files from a previous snapshot to the current bucket.
The restored files will overwrite any existing files at the same path.
Examples:
bm cloud restore notes/project.md --snapshot abc123
bm cloud restore research/ --snapshot abc123
bm cloud restore notes/project.md --snapshot abc123 --force
"""
async def _restore():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
# Normalize path - remove leading slash if present
normalized_path = path.lstrip("/")
if not force:
# Show what will be restored
console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]")
console.print(f" Path: {normalized_path}")
# Try to browse the snapshot to show what files will be affected
try:
browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
if normalized_path:
browse_url += f"?prefix={normalized_path}"
response = await make_api_request(
method="GET",
url=browse_url,
)
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
if browse_response.files:
if len(browse_response.files) <= 10:
console.print("\n Files to restore:")
for file_info in browse_response.files:
console.print(f" - {file_info.key}")
else:
console.print(
f"\n {len(browse_response.files)} files will be restored"
)
console.print(" First 5 files:")
for file_info in browse_response.files[:5]:
console.print(f" - {file_info.key}")
console.print(f" ... and {len(browse_response.files) - 5} more")
else:
console.print(
f"\n[yellow]No files found matching '{normalized_path}' "
f"in snapshot[/yellow]"
)
raise typer.Exit(0)
except CloudAPIError as browse_error:
if browse_error.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
raise typer.Exit(1)
# If browse fails for other reasons, proceed with confirmation anyway
pass
console.print(
"\n[yellow]Warning: Restored files will overwrite existing files![/yellow]"
)
confirmed = typer.confirm("\nProceed with restore?")
if not confirmed:
console.print("[yellow]Restore cancelled[/yellow]")
raise typer.Exit(0)
console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore",
json_data={"path": normalized_path},
)
data = response.json()
restored_files = data.get("restored", [])
returned_snapshot_id = data.get("snapshot_id", snapshot_id)
if restored_files:
console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]")
if len(restored_files) <= 10:
for file_path in restored_files:
console.print(f" - {file_path}")
else:
console.print(" First 5 restored files:")
for file_path in restored_files[:5]:
console.print(f" - {file_path}")
console.print(f" ... and {len(restored_files) - 5} more")
console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]")
else:
console.print("[yellow]No files were restored[/yellow]")
console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]")
except typer.Exit:
# Re-raise typer.Exit without modification - it's used for clean exits
raise
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to restore: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_restore())
@@ -0,0 +1,55 @@
"""Pydantic schemas for Basic Memory Cloud API responses.
These schemas mirror the API response models from basic-memory-cloud
for type-safe parsing of API responses in CLI commands.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class BucketSnapshotFileInfo(BaseModel):
"""File info from snapshot browse response."""
key: str
size: int
last_modified: datetime
etag: str | None = None
class BucketSnapshotBrowseResponse(BaseModel):
"""Response from browsing snapshot contents."""
files: list[BucketSnapshotFileInfo]
prefix: str
snapshot_version: str
class BucketSnapshotResponse(BaseModel):
"""Response model for bucket snapshot data."""
id: UUID
bucket_name: str
snapshot_version: str
name: str
description: str | None
auto: bool
created_at: datetime
created_by: UUID | None = None
class BucketSnapshotListResponse(BaseModel):
"""Response from listing bucket snapshots."""
snapshots: list[BucketSnapshotResponse]
total: int
class BucketSnapshotRestoreResponse(BaseModel):
"""Response from restore operation."""
restored: list[str]
snapshot_version: str
snapshot_id: UUID
@@ -0,0 +1,370 @@
"""Snapshot CLI commands for Basic Memory Cloud.
SPEC-29 Phase 3: CLI commands for managing Tigris bucket snapshots.
"""
import asyncio
from datetime import datetime
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
make_api_request,
)
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
from basic_memory.config import ConfigManager
console = Console()
snapshot_app = typer.Typer(help="Manage bucket snapshots")
def _format_timestamp(iso_timestamp: str) -> str:
"""Format ISO timestamp to a human-readable format."""
try:
dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError):
return iso_timestamp
@snapshot_app.command("create")
def create(
description: str = typer.Argument(
...,
help="Description for the snapshot",
),
) -> None:
"""Create a new bucket snapshot.
Examples:
bm cloud snapshot create "before major refactor"
bm cloud snapshot create "daily backup"
"""
async def _create():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
console.print("[blue]Creating snapshot...[/blue]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/bucket-snapshots",
json_data={"description": description},
)
data = response.json()
snapshot_id = data.get("id", "unknown")
snapshot_version = data.get("snapshot_version", "unknown")
created_at = _format_timestamp(data.get("created_at", ""))
console.print("[green]Snapshot created successfully[/green]")
console.print(f" ID: {snapshot_id}")
console.print(f" Version: {snapshot_version}")
console.print(f" Created: {created_at}")
console.print(f" Description: {description}")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
console.print(f"[red]Failed to create snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_create())
@snapshot_app.command("list")
def list_snapshots(
limit: int = typer.Option(
10,
"--limit",
"-l",
help="Maximum number of snapshots to display",
),
) -> None:
"""List all bucket snapshots.
Examples:
bm cloud snapshot list
bm cloud snapshot list --limit 20
"""
async def _list():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
console.print("[blue]Fetching snapshots...[/blue]")
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots",
)
data = response.json()
snapshots = data.get("snapshots", [])
total = data.get("total", len(snapshots))
if not snapshots:
console.print("[yellow]No snapshots found[/yellow]")
console.print(
'\n[dim]Create a snapshot with: bm cloud snapshot create "description"[/dim]'
)
return
# Create a table for displaying snapshots
table = Table(title=f"Bucket Snapshots ({total} total)")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Description", style="white")
table.add_column("Auto", style="dim")
table.add_column("Created", style="green")
for snapshot in snapshots[:limit]:
snapshot_id = snapshot.get("id", "unknown")
desc = snapshot.get("description") or snapshot.get("name", "-")
auto = "yes" if snapshot.get("auto", False) else "no"
created_at = _format_timestamp(snapshot.get("created_at", ""))
table.add_row(snapshot_id, desc, auto, created_at)
console.print(table)
if total > limit:
console.print(
f"\n[dim]Showing {limit} of {total} snapshots. Use --limit to see more.[/dim]"
)
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
console.print(f"[red]Failed to list snapshots: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_list())
@snapshot_app.command("delete")
def delete(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to delete",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation prompt",
),
) -> None:
"""Delete a bucket snapshot.
Examples:
bm cloud snapshot delete abc123
bm cloud snapshot delete abc123 --force
"""
async def _delete():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
if not force:
# Fetch snapshot details first to show what will be deleted
console.print("[blue]Fetching snapshot details...[/blue]")
try:
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
data = response.json()
desc = data.get("description") or data.get("name", "unnamed")
created_at = _format_timestamp(data.get("created_at", ""))
console.print("\nSnapshot to delete:")
console.print(f" ID: {snapshot_id}")
console.print(f" Description: {desc}")
console.print(f" Created: {created_at}")
except CloudAPIError:
# If we can't fetch details, proceed with confirmation anyway
pass
confirmed = typer.confirm("\nAre you sure you want to delete this snapshot?")
if not confirmed:
console.print("[yellow]Deletion cancelled[/yellow]")
raise typer.Exit(0)
console.print("[blue]Deleting snapshot...[/blue]")
await make_api_request(
method="DELETE",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
console.print(f"[green]Snapshot {snapshot_id} deleted successfully[/green]")
except typer.Exit:
# Re-raise typer.Exit without modification - it's used for clean exits
raise
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to delete snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_delete())
@snapshot_app.command("show")
def show(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to show",
),
) -> None:
"""Show details of a specific snapshot.
Examples:
bm cloud snapshot show abc123
"""
async def _show():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
data = response.json()
console.print("[bold blue]Snapshot Details[/bold blue]")
console.print(f" ID: {data.get('id', 'unknown')}")
console.print(f" Bucket: {data.get('bucket_name', 'unknown')}")
console.print(f" Version: {data.get('snapshot_version', 'unknown')}")
console.print(f" Name: {data.get('name', '-')}")
console.print(f" Description: {data.get('description') or '-'}")
console.print(f" Auto: {'yes' if data.get('auto', False) else 'no'}")
console.print(f" Created: {_format_timestamp(data.get('created_at', ''))}")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to get snapshot details: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_show())
@snapshot_app.command("browse")
def browse(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to browse",
),
prefix: Optional[str] = typer.Option(
None,
"--prefix",
"-p",
help="Filter files by path prefix (e.g., 'notes/')",
),
) -> None:
"""Browse contents of a snapshot.
Examples:
bm cloud snapshot browse abc123
bm cloud snapshot browse abc123 --prefix notes/
"""
async def _browse():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
if prefix:
url += f"?prefix={prefix}"
response = await make_api_request(
method="GET",
url=url,
)
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
if not browse_response.files:
if prefix:
console.print(f"[yellow]No files found with prefix '{prefix}'[/yellow]")
else:
console.print("[yellow]No files found in snapshot[/yellow]")
return
console.print(
f"[bold blue]Snapshot Contents ({len(browse_response.files)} files)[/bold blue]"
)
for file_info in browse_response.files:
size_kb = file_info.size // 1024
console.print(f" {file_info.key} ({size_kb} KB)")
console.print(
f"\n[dim]Use 'bm cloud restore <path> --snapshot {snapshot_id}' "
f"to restore files[/dim]"
)
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to browse snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_browse())
@@ -14,7 +14,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.telemetry import shutdown_telemetry
console = Console()
@@ -24,8 +23,8 @@ T = TypeVar("T")
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine with proper database cleanup.
This helper ensures database connections and telemetry threads are cleaned up
before the event loop closes, preventing process hangs in CLI commands.
This helper ensures database connections are cleaned up before the
event loop closes, preventing process hangs in CLI commands.
Args:
coro: The coroutine to run
@@ -39,9 +38,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
return await coro
finally:
await db.shutdown_db()
# Shutdown telemetry to stop the OpenPanel background thread
# This prevents hangs on Python 3.14+ during thread shutdown
shutdown_telemetry()
return asyncio.run(_with_cleanup())
+52 -48
View File
@@ -17,60 +17,64 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
from loguru import logger
config = ConfigManager().config
if not config.cloud_mode_enabled:
@app.command()
def mcp(
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
@app.command()
def mcp(
transport: str = typer.Option(
"stdio", help="Transport type: stdio, streamable-http, or sse"
),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
This command starts an MCP server using one of three transport options:
This command starts an MCP server using one of three transport options:
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
"""
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
Note: This command is available regardless of cloud mode setting.
Users who have cloud mode enabled can still use local MCP for Claude Code
and Claude Desktop while using cloud MCP for web and mobile access.
"""
# Force local routing for local MCP server
# Why: The local MCP server should always talk to the local API, not the cloud proxy.
# Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access.
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
+136 -44
View File
@@ -1,32 +1,32 @@
"""Command module for basic-memory project management."""
import json
import os
from datetime import datetime
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
import json
from datetime import datetime
from rich.panel import Panel
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.utils import generate_permalink, normalize_project_path
# Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import (
SyncProject,
RcloneError,
project_sync,
SyncProject,
project_bisync,
project_check,
project_ls,
project_sync,
)
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
@@ -46,8 +46,22 @@ def format_path(path: str) -> str:
@project_app.command("list")
def list_projects() -> None:
"""List all Basic Memory projects."""
def list_projects(
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""List all Basic Memory projects.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
async def _list_projects():
async with get_client() as client:
@@ -55,19 +69,20 @@ def list_projects() -> None:
return ProjectList.model_validate(response.json())
try:
result = run_with_cleanup(_list_projects())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_list_projects())
config = ConfigManager().config
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
# Add Local Path column if in cloud mode
if config.cloud_mode_enabled:
# Add Local Path column if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
show_default_column = not config.cloud_mode_enabled or config.default_project_mode
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
if show_default_column:
table.add_column("Default", style="magenta")
@@ -78,8 +93,8 @@ def list_projects() -> None:
# Build row based on mode
row = [project.name, format_path(normalized_path)]
# Add local path if in cloud mode
if config.cloud_mode_enabled:
# Add local path if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
local_path = ""
if project.name in config.cloud_projects:
local_path = config.cloud_projects[project.name].local_path or ""
@@ -108,9 +123,16 @@ def add_project(
None, "--local-path", help="Local sync path for cloud mode (optional)"
),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Add a new project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
Cloud mode examples:\n
bm project add research # No local sync\n
bm project add research --local-path ~/docs # With local sync\n
@@ -118,14 +140,23 @@ def add_project(
Local mode example:\n
bm project add research ~/Documents/research
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
config = ConfigManager().config
# Determine effective mode: local flag forces local mode behavior
effective_cloud_mode = config.cloud_mode_enabled and not local
# Resolve local sync path early (needed for both cloud and local mode)
local_sync_path: str | None = None
if local_path:
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
if config.cloud_mode_enabled:
if effective_cloud_mode:
# Cloud mode: path auto-generated from name, local sync is optional
async def _add_project():
@@ -154,11 +185,12 @@ def add_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_add_project())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_add_project())
console.print(f"[green]{result.message}[/green]")
# Save local sync path to config if in cloud mode
if config.cloud_mode_enabled and local_sync_path:
if effective_cloud_mode and local_sync_path:
from basic_memory.config import CloudProjectConfig
# Create local directory if it doesn't exist
@@ -243,8 +275,21 @@ def remove_project(
delete_notes: bool = typer.Option(
False, "--delete-notes", help="Delete project files from disk"
),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Remove a project."""
"""Remove a project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
async def _remove_project():
async with get_client() as client:
@@ -265,11 +310,11 @@ def remove_project(
try:
# Get config to check for local sync path and bisync state
config = ConfigManager().config
local_path = None
local_path_config = None
has_bisync_state = False
if config.cloud_mode_enabled and name in config.cloud_projects:
local_path = config.cloud_projects[name].local_path
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
local_path_config = config.cloud_projects[name].local_path
# Check for bisync state
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
@@ -278,17 +323,18 @@ def remove_project(
has_bisync_state = bisync_state_path.exists()
# Remove project from cloud/API
result = run_with_cleanup(_remove_project())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_remove_project())
console.print(f"[green]{result.message}[/green]")
# Clean up local sync directory if it exists and delete_notes is True
if delete_notes and local_path:
local_dir = Path(local_path)
if delete_notes and local_path_config:
local_dir = Path(local_path_config)
if local_dir.exists():
import shutil
shutil.rmtree(local_dir)
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
console.print(f"[green]Removed local sync directory: {local_path_config}[/green]")
# Clean up bisync state if it exists
if has_bisync_state:
@@ -301,14 +347,14 @@ def remove_project(
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and name in config.cloud_projects:
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
del config.cloud_projects[name]
ConfigManager().save_config(config)
# Show informative message if files were not deleted
if not delete_notes:
if local_path:
console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
if local_path_config:
console.print(f"[yellow]Note: Local files remain at {local_path_config}[/yellow]")
except Exception as e:
console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -318,15 +364,24 @@ def remove_project(
@project_app.command("default")
def set_default_project(
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Set the default project when 'config.default_project_mode' is set.
Note: This command is only available in local mode.
In cloud mode, use --local to modify the local configuration.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: default project is a local configuration concept
# Outcome: require explicit --local flag to modify local config in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'default' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project default <name> --local' to set local default[/yellow]"
)
raise typer.Exit(1)
async def _set_default():
@@ -346,7 +401,8 @@ def set_default_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_set_default())
with force_routing(local=local):
result = run_with_cleanup(_set_default())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -354,15 +410,25 @@ def set_default_project(
@project_app.command("sync-config")
def synchronize_projects() -> None:
def synchronize_projects(
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Synchronize project config between configuration file and database.
Note: This command is only available in local mode.
In cloud mode, use --local to sync local configuration.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: sync-config syncs local config file with local database
# Outcome: require explicit --local flag to clarify intent in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'sync-config' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project sync-config --local' to sync local config[/yellow]"
)
raise typer.Exit(1)
async def _sync_config():
@@ -371,7 +437,8 @@ def synchronize_projects() -> None:
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_sync_config())
with force_routing(local=local):
result = run_with_cleanup(_sync_config())
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -382,15 +449,24 @@ def synchronize_projects() -> None:
def move_project(
name: str = typer.Argument(..., help="Name of the project to move"),
new_path: str = typer.Argument(..., help="New absolute path for the project"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Move a project to a new location.
Note: This command is only available in local mode.
In cloud mode, use --local to modify local project paths.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: moving a project is a local file system operation
# Outcome: require explicit --local flag to clarify intent in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'move' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project move <name> <path> --local' to move local project[/yellow]"
)
raise typer.Exit(1)
# Resolve to absolute path
@@ -406,7 +482,8 @@ def move_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_move_project())
with force_routing(local=local):
result = run_with_cleanup(_move_project())
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
@@ -779,11 +856,26 @@ def ls_project_command(
def display_project_info(
name: str = typer.Argument(..., help="Name of the project"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Display detailed information and statistics about the current project."""
"""Display detailed information and statistics about the current project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
try:
# Get project info
info = run_with_cleanup(get_project_info(name))
with force_routing(local=local, cloud=cloud):
info = run_with_cleanup(get_project_info(name))
if json_output:
# Convert to JSON and print
+73
View File
@@ -0,0 +1,73 @@
"""CLI routing utilities for --local/--cloud flag handling.
This module provides utilities for CLI commands to override the default routing
behavior (determined by cloud_mode_enabled in config). This allows users to:
1. Use local MCP server even when cloud mode is enabled
2. Force local routing for specific CLI commands with --local flag
3. Force cloud routing with --cloud flag (requires authentication)
The routing is controlled via environment variables:
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
- These are checked in basic_memory.mcp.async_client.get_client()
"""
import os
from contextlib import contextmanager
from typing import Generator
@contextmanager
def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, None, None]:
"""Context manager to temporarily override routing mode.
Sets environment variables that are checked by get_client() to determine
whether to use local ASGI transport or cloud proxy transport.
Args:
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
cloud: If True, clear force_local to allow cloud routing
Usage:
with force_routing(local=True):
# All API calls will use local ASGI transport
await some_api_call()
Raises:
ValueError: If both local and cloud are True
"""
if local and cloud:
raise ValueError("Cannot specify both --local and --cloud")
# Save original values
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
try:
if local:
# Force local routing by setting the env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
elif cloud:
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
# If neither is set, don't change anything (use default behavior)
yield
finally:
# Restore original value
if original_force_local is None:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
else:
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
def validate_routing_flags(local: bool, cloud: bool) -> None:
"""Validate that --local and --cloud flags are not both specified.
Args:
local: Value of --local flag
cloud: Value of --cloud flag
Raises:
ValueError: If both flags are True
"""
if local and cloud:
raise ValueError("Cannot specify both --local and --cloud flags")
+16 -2
View File
@@ -11,6 +11,7 @@ from rich.panel import Panel
from rich.tree import Tree
from basic_memory.cli.app import app
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas import SyncReportResponse
@@ -162,12 +163,25 @@ def status(
typer.Option(help="The project name."),
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Show sync status between files and database."""
"""Show sync status between files and database.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
try:
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except Exception as e:
logger.error(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
@@ -1,81 +0,0 @@
"""Telemetry commands for basic-memory CLI."""
import typer
from rich.console import Console
from rich.panel import Panel
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager
console = Console()
# Create telemetry subcommand group
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
app.add_typer(telemetry_app, name="telemetry")
@telemetry_app.command("enable")
def enable() -> None:
"""Enable anonymous telemetry.
Telemetry helps improve Basic Memory by collecting anonymous usage data.
No personal data, note content, or file paths are ever collected.
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = True
config_manager.save_config(config)
console.print("[green]Telemetry enabled[/green]")
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
@telemetry_app.command("disable")
def disable() -> None:
"""Disable anonymous telemetry.
You can re-enable telemetry anytime with: bm telemetry enable
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = False
config_manager.save_config(config)
console.print("[yellow]Telemetry disabled[/yellow]")
@telemetry_app.command("status")
def status() -> None:
"""Show current telemetry status and what's collected."""
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
config = ConfigManager().config
status_text = (
"[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
)
console.print(f"\nTelemetry: {status_text}")
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
console.print()
what_we_collect = """
[bold]What we collect:[/bold]
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands)
- Sync statistics (entity count, duration)
- Error types (sanitized, no file paths)
[bold]What we NEVER collect:[/bold]
- Note content, file names, or paths
- Personal information
- IP addresses
"""
console.print(
Panel(
what_we_collect.strip(),
title="Telemetry Details",
border_style="blue",
expand=False,
)
)
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
+156 -74
View File
@@ -9,6 +9,7 @@ from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
# Import prompts
@@ -50,6 +51,10 @@ def write_note(
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Create or update a markdown note. Content can be provided as an argument or read from stdin.
@@ -57,6 +62,9 @@ def write_note(
1. Using the --content parameter
2. Piping content through stdin (if --content is not provided)
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
Examples:
# Using content parameter
@@ -77,8 +85,13 @@ def write_note(
# Reading from a file
cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
# Force local routing in cloud mode
basic-memory tools write-note --title "My Note" --folder "notes" --content "..." --local
"""
try:
validate_routing_flags(local, cloud)
# If content is not provided, read from stdin
if content is None:
# Check if we're getting data from a pipe or redirect
@@ -109,8 +122,12 @@ def write_note(
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
with force_routing(local=local, cloud=cloud):
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
rprint(note)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during write_note: {e}", err=True)
@@ -129,24 +146,37 @@ def read_note(
] = None,
page: int = 1,
page_size: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Read a markdown note from the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
"""Read a markdown note from the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
rprint(note)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during read_note: {e}", err=True)
@@ -166,38 +196,51 @@ def build_context(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get context needed to continue a discussion."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
"""Get context needed to continue a discussion.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
context = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
context = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
)
# Use json module for more controlled serialization
import json
context_dict = context.model_dump(exclude_none=True)
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
@@ -210,18 +253,32 @@ def recent_activity(
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get recent activity across the knowledge base."""
"""Get recent activity across the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
)
)
)
# The tool now returns a formatted string directly
print(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during recent_activity: {e}", err=True)
@@ -246,26 +303,31 @@ def search_notes(
] = None,
page: int = 1,
page_size: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Search across all content in the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
if permalink and title: # pragma: no cover
print("Cannot search both permalink and title")
raise typer.Abort()
"""Search across all content in the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
if permalink and title: # pragma: no cover
typer.echo(
"Use either --permalink or --title, not both. Exiting.",
@@ -279,21 +341,25 @@ def search_notes(
search_type = ("title" if title else None,)
search_type = "text" if search_type is None else search_type
results = run_with_cleanup(
mcp_search.fn(
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
with force_routing(local=local, cloud=cloud):
results = run_with_cleanup(
mcp_search.fn(
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
)
)
)
# Use json module for more controlled serialization
import json
results_dict = results.model_dump(exclude_none=True)
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error during search", e)
@@ -308,12 +374,28 @@ def continue_conversation(
timeframe: Annotated[
Optional[str], typer.Option(help="How far back to look for activity")
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Prompt to continue a previous conversation or work session."""
"""Prompt to continue a previous conversation or work session.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
# Prompt functions return formatted strings directly
session = run_with_cleanup(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
# Prompt functions return formatted strings directly
session = run_with_cleanup(
mcp_continue_conversation.fn(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
)
rprint(session)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error continuing conversation", e)
-1
View File
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
telemetry,
tool,
)
+1 -12
View File
@@ -221,17 +221,6 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
# Telemetry configuration (Homebrew-style opt-out)
telemetry_enabled: bool = Field(
default=True,
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
)
telemetry_notice_shown: bool = Field(
default=False,
description="Whether the one-time telemetry notice has been shown to the user",
)
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -241,7 +230,7 @@ class BasicMemoryConfig(BaseSettings):
- BASIC_MEMORY_ENV environment variable is "test"
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
Used to disable features like telemetry and file watchers during tests.
Used to disable features like file watchers during tests.
"""
return (
self.env == "test"
+4 -4
View File
@@ -450,16 +450,16 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
return text.strip(replacement)
def sanitize_for_folder(folder: str) -> str:
def sanitize_for_directory(directory: str) -> str:
"""
Sanitize folder path to be safe for use in file system paths.
Sanitize directory path to be safe for use in file system paths.
Removes leading/trailing whitespace, compresses multiple slashes,
and removes special characters except for /, -, and _.
"""
if not folder:
if not directory:
return ""
sanitized = folder.strip()
sanitized = directory.strip()
if sanitized.startswith("./"):
sanitized = sanitized[2:]
+7
View File
@@ -1,5 +1,6 @@
"""Utilities for converting between markdown and entity models."""
import uuid
from pathlib import Path
from typing import Any, Optional
@@ -40,6 +41,12 @@ def entity_model_from_markdown(
# Create or update entity
model = entity or Entity()
# Ensure external_id is set for new entities
# SQLAlchemy's Python-side default may not always evaluate,
# so we explicitly set it here for reliability (fixes #512)
if not model.external_id:
model.external_id = str(uuid.uuid4())
# Update basic fields
model.title = markdown.frontmatter.title
model.entity_type = markdown.frontmatter.type
+32 -2
View File
@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager, AbstractAsyncContextManager
from typing import AsyncIterator, Callable, Optional
@@ -8,6 +9,19 @@ from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
def _force_local_mode() -> bool:
"""Check if local mode is forced via environment variable.
This allows commands like `bm mcp` to force local routing even when
cloud_mode_enabled is True in config. The local MCP server should
always talk to the local API, not the cloud proxy.
Returns:
True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value
"""
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
# Optional factory override for dependency injection
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
@@ -71,7 +85,17 @@ async def get_client() -> AsyncIterator[AsyncClient]:
pool=30.0, # 30 seconds for connection pool
)
if config.cloud_mode_enabled:
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
# Why: allows local MCP server and CLI commands to route locally
# even when cloud_mode_enabled is True
# Outcome: uses ASGI transport for in-process local API calls
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
elif config.cloud_mode_enabled:
# CLI cloud mode: inject auth when creating client
from basic_memory.cli.auth import CLIAuth
@@ -126,7 +150,13 @@ def create_client() -> AsyncClient:
pool=30.0, # 30 seconds for connection pool
)
if config.cloud_mode_enabled:
# Check force local first (for local MCP server and CLI --local flag)
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
elif config.cloud_mode_enabled:
# Use HTTP transport to proxy endpoint
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
+50 -1
View File
@@ -8,7 +8,12 @@ from typing import Any
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
from basic_memory.schemas.response import (
EntityResponse,
DeleteEntitiesResponse,
DirectoryMoveResult,
DirectoryDeleteResult,
)
class KnowledgeClient:
@@ -153,6 +158,50 @@ class KnowledgeClient:
)
return EntityResponse.model_validate(response.json())
async def move_directory(
self, source_directory: str, destination_directory: str
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
Args:
source_directory: Source directory path (relative to project root)
destination_directory: Destination directory path (relative to project root)
Returns:
DirectoryMoveResult with counts and details of moved files
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"{self._base_path}/move-directory",
json={
"source_directory": source_directory,
"destination_directory": destination_directory,
},
)
return DirectoryMoveResult.model_validate(response.json())
async def delete_directory(self, directory: str) -> DirectoryDeleteResult:
"""Delete all entities in a directory.
Args:
directory: Directory path to delete (relative to project root)
Returns:
DirectoryDeleteResult with counts and details of deleted files
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"{self._base_path}/delete-directory",
json={"directory": directory},
)
return DirectoryDeleteResult.model_validate(response.json())
# --- Resolution ---
async def resolve_entity(self, identifier: str) -> str:
@@ -13,7 +13,6 @@ from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.prompt import ContinueConversationRequest
@@ -24,7 +23,7 @@ from basic_memory.schemas.prompt import ContinueConversationRequest
async def continue_conversation(
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[TimeFrame],
Optional[str],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
+47 -135
View File
@@ -3,17 +3,14 @@
These prompts help users see what has changed in their knowledge base recently.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext, ProjectActivitySummary
from basic_memory.schemas.search import SearchItemType
@mcp.prompt(
@@ -22,7 +19,7 @@ from basic_memory.schemas.search import SearchItemType
)
async def recent_activity_prompt(
timeframe: Annotated[
TimeFrame,
str,
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = "7d",
project: Annotated[
@@ -47,142 +44,57 @@ async def recent_activity_prompt(
"""
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
recent = await recent_activity.fn(
project=project, timeframe=timeframe, type=[SearchItemType.ENTITY]
# Call the tool function - it returns a well-formatted string
# Pass type as string values (not enum) to match the tool's expected input
activity_summary = await recent_activity.fn(
project=project, timeframe=timeframe, type="entity"
)
# Extract primary results from the hierarchical structure
primary_results = []
related_results = []
# Build the prompt response
# The tool already returns formatted markdown, so we use it directly
# and add prompt-specific guidance
target = project if project else "all projects"
if isinstance(recent, ProjectActivitySummary):
# Discovery mode - extract results from all projects
for _, project_activity in recent.projects.items():
if project_activity.activity.results:
# Take up to 2 primary results per project
for item in project_activity.activity.results[:2]:
primary_results.append(item.primary_result)
# Add up to 1 related result per primary item
if item.related_results:
related_results.extend(item.related_results[:1]) # pragma: no cover
prompt_guidance = dedent(f"""
# Recent Activity Context
# Limit total results for readability
primary_results = primary_results[:8]
related_results = related_results[:6]
This is a memory retrieval session showing recent activity from {target}.
elif isinstance(recent, GraphContext):
# Project-specific mode - use existing logic
if recent.results:
# Take up to 5 primary results
for item in recent.results[:5]:
primary_results.append(item.primary_result)
# Add up to 2 related results per primary item
if item.related_results:
related_results.extend(item.related_results[:2]) # pragma: no cover
{activity_summary}
# Set topic based on mode
if project:
topic = f"Recent Activity in {project} ({timeframe})"
else:
topic = f"Recent Activity Across All Projects ({timeframe})"
---
prompt_context = format_prompt_context(
PromptContext(
topic=topic,
timeframe=timeframe,
results=[
PromptContextItem(
primary_results=primary_results,
related_results=related_results[:10], # Limit total related results
)
],
## Next Steps
Based on this activity, you can:
1. **Explore specific items** - Use `read_note("permalink")` to dive deeper into any item
2. **Search for related content** - Use `search_notes("topic")` to find connected knowledge
3. **Build context** - Use `build_context("memory://path")` to see relationships
## Capture Opportunity
If you notice patterns or insights from this activity, consider documenting them:
```python
write_note(
title="Activity Insights - {timeframe}",
content='''
# Activity Insights
## Patterns Observed
- [trend] [Pattern you noticed in the activity]
## Key Developments
- [insight] [Important development worth tracking]
## Relations
- summarizes [[Recent Work]]
''',
folder="insights",
project="{project or 'default'}"
)
)
```
""")
# Add mode-specific suggestions
first_title = "Recent Topic"
if primary_results and len(primary_results) > 0:
first_title = primary_results[0].title
if project:
# Project-specific suggestions
capture_suggestions = f"""
## Opportunity to Capture Activity Summary
Consider creating a summary note of recent activity in {project}:
```python
await write_note(
"{project}",
title="Activity Summary {timeframe}",
content='''
# Activity Summary for {project} ({timeframe})
## Overview
[Summary of key changes and developments in this project over this period]
## Key Updates
[List main updates and their significance within this project]
## Observations
- [trend] [Observation about patterns in recent activity]
- [insight] [Connection between different activities]
## Relations
- summarizes [[{first_title}]]
- relates_to [[{project} Overview]]
''',
folder="summaries"
)
```
Summarizing periodic activity helps create high-level insights and connections within the project.
"""
else:
# Discovery mode suggestions
project_count = len(recent.projects) if isinstance(recent, ProjectActivitySummary) else 0
most_active = (
getattr(recent.summary, "most_active_project", "Unknown")
if isinstance(recent, ProjectActivitySummary)
else "Unknown"
)
capture_suggestions = f"""
## Cross-Project Activity Discovery
Found activity across {project_count} projects. Most active: **{most_active}**
Consider creating a cross-project summary:
```python
await write_note(
"{most_active if most_active != "Unknown" else "main"}",
title="Cross-Project Activity Summary {timeframe}",
content='''
# Cross-Project Activity Summary ({timeframe})
## Overview
Activity found across {project_count} projects, with {most_active} showing the most activity.
## Key Developments
[Summarize important changes across all projects]
## Project Insights
[Note patterns or connections between projects]
## Observations
- [trend] [Cross-project patterns observed]
- [insight] [Connections between different project activities]
## Relations
- summarizes [[{first_title}]]
- relates_to [[Project Portfolio Overview]]
''',
folder="summaries"
)
```
Cross-project summaries help identify broader trends and project interconnections.
"""
return prompt_context + capture_suggestions
return prompt_guidance
+1 -2
View File
@@ -12,7 +12,6 @@ from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.prompt import SearchPromptRequest
@@ -23,7 +22,7 @@ from basic_memory.schemas.prompt import SearchPromptRequest
async def search_prompt(
query: str,
timeframe: Annotated[
Optional[TimeFrame],
Optional[str],
Field(description="How far back to search (e.g. '1d', '1 week')"),
] = None,
) -> str:
+1 -2
View File
@@ -8,7 +8,6 @@ from dataclasses import dataclass
from textwrap import dedent
from typing import List
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
normalize_memory_url,
EntitySummary,
@@ -25,7 +24,7 @@ class PromptContextItem:
@dataclass
class PromptContext:
timeframe: TimeFrame
timeframe: str
topic: str
results: List[PromptContextItem]
-6
View File
@@ -10,7 +10,6 @@ from loguru import logger
from basic_memory import db
from basic_memory.mcp.container import McpContainer, set_container
from basic_memory.services.initialization import initialize_app
from basic_memory.telemetry import show_notice_if_needed, track_app_started
@asynccontextmanager
@@ -19,7 +18,6 @@ async def lifespan(app: FastMCP):
Handles:
- Database initialization and migrations
- Telemetry notice and tracking
- File sync via SyncCoordinator (if enabled and not in cloud mode)
- Proper cleanup on shutdown
"""
@@ -30,10 +28,6 @@ async def lifespan(app: FastMCP):
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
# Show telemetry notice (first run only) and track startup
show_notice_if_needed()
track_app_started("mcp")
# Track if we created the engine (vs test fixtures providing it)
# This prevents disposing an engine provided by test fixtures when
# multiple Client connections are made in the same test
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -87,7 +86,6 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
track_mcp_tool("build_context")
logger.info(f"Building context from {url} in project {project}")
# Convert string depth to integer if needed
+11 -13
View File
@@ -13,7 +13,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -23,7 +22,7 @@ async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
title: str,
folder: str,
directory: str,
project: Optional[str] = None,
context: Context | None = None,
) -> str:
@@ -44,8 +43,8 @@ async def canvas(
nodes: List of node objects following JSON Canvas 1.0 spec
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: Folder path relative to project root where the canvas should be saved.
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
directory: Directory path relative to project root where the canvas should be saved.
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
context: Optional FastMCP context for performance caching.
Returns:
@@ -53,7 +52,7 @@ async def canvas(
Important Notes:
- When referencing files, use the exact file path as shown in Obsidian
Example: "folder/Document Name.md" (not permalink format)
Example: "docs/Document Name.md" (not permalink format)
- For file nodes, the "file" attribute must reference an existing file
- Nodes require id, type, x, y, width, height properties
- Edges require id, fromNode, toNode properties
@@ -67,7 +66,7 @@ async def canvas(
{
"id": "node1",
"type": "file", // Options: "file", "text", "link", "group"
"file": "folder/Document.md",
"file": "docs/Document.md",
"x": 0,
"y": 0,
"width": 400,
@@ -86,22 +85,21 @@ async def canvas(
```
Examples:
# Create canvas in project
canvas("my-project", nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
# Create canvas in default/current project
canvas(nodes=[...], edges=[...], title="My Canvas", directory="diagrams")
# Create canvas in work project
canvas("work-project", nodes=[...], edges=[...], title="Process Flow", folder="visual/maps")
# Create canvas with explicit project
canvas(nodes=[...], edges=[...], title="Process Flow", directory="visual/maps", project="work-project")
Raises:
ToolError: If project doesn't exist or folder path is invalid
ToolError: If project doesn't exist or directory path is invalid
"""
track_mcp_tool("canvas")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
file_path = f"{directory}/{file_title}"
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
@@ -15,7 +15,6 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.schemas.search import SearchResponse
from basic_memory.config import ConfigManager
from basic_memory.telemetry import track_mcp_tool
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
@@ -89,7 +88,6 @@ async def search(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body contains `results`, `total_count`, and echo of `query`.
"""
track_mcp_tool("search")
logger.info(f"ChatGPT search request: query='{query}'")
try:
@@ -153,7 +151,6 @@ async def fetch(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
"""
track_mcp_tool("fetch")
logger.info(f"ChatGPT fetch request: id='{id}'")
try:
+94 -16
View File
@@ -8,7 +8,6 @@ from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.telemetry import track_mcp_tool
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -148,43 +147,58 @@ delete_note("{project}", "correct-identifier-from-search")
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
@mcp.tool(description="Delete a note by title or permalink")
@mcp.tool(description="Delete a note or directory by title, permalink, or path")
async def delete_note(
identifier: str, project: Optional[str] = None, context: Context | None = None
identifier: str,
is_directory: bool = False,
project: Optional[str] = None,
context: Context | None = None,
) -> bool | str:
"""Delete a note from the knowledge base.
"""Delete a note or directory from the knowledge base.
Permanently removes a note from the specified project. The note is identified
by title or permalink. If the note doesn't exist, the operation returns False
without error. If deletion fails due to other issues, helpful error messages are provided.
Permanently removes a note or directory from the specified project. For single notes,
they are identified by title or permalink. For directories, use is_directory=True and
provide the directory path. If the note/directory doesn't exist, the operation returns
False without error. If deletion fails, helpful error messages are provided.
Project Resolution:
Server resolves projects in this order: Single Project Mode → project parameter → default project.
If project unknown, use list_memory_projects() or recent_activity() first.
Args:
identifier: For files: note title or permalink to delete.
For directories: the directory path (e.g., "docs", "projects/2025").
Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
is_directory: If True, deletes an entire directory and all its contents.
When True, identifier should be a directory path
(without file extensions). Defaults to False.
project: Project name to delete from. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
identifier: Note title or permalink to delete
Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
context: Optional FastMCP context for performance caching.
Returns:
True if note was successfully deleted, False if note was not found.
For directories, returns a formatted summary of deleted files.
On errors, returns a formatted string with helpful troubleshooting guidance.
Examples:
# Delete by title
delete_note("my-project", "Meeting Notes: Project Planning")
delete_note("Meeting Notes: Project Planning")
# Delete by permalink
delete_note("work-docs", "notes/project-planning")
delete_note("notes/project-planning")
# Delete with exact path
delete_note("research", "experiments/ml-model-results")
# Delete with explicit project
delete_note("experiments/ml-model-results", project="research")
# Delete entire directory
delete_note("docs", is_directory=True)
# Delete nested directory
delete_note("projects/2024", is_directory=True)
# Common usage pattern
if delete_note("my-project", "old-draft"):
if delete_note("old-draft"):
print("Note deleted successfully")
else:
print("Note not found or already deleted")
@@ -194,7 +208,7 @@ async def delete_note(
SecurityError: If identifier attempts path traversal
Warning:
This operation is permanent and cannot be undone. The note file
This operation is permanent and cannot be undone. The note/directory files
will be removed from the filesystem and all references will be lost.
Note:
@@ -202,8 +216,11 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
track_mcp_tool("delete_note")
async with get_client() as client:
logger.debug(
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {project}"
)
active_project = await get_active_project(client, project, context)
# Import here to avoid circular import
@@ -212,6 +229,67 @@ async def delete_note(
# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
# Handle directory deletes
if is_directory:
try:
result = await knowledge_client.delete_directory(identifier)
# Build success message for directory delete
result_lines = [
"# Directory Deleted Successfully",
"",
f"**Directory:** `{identifier}`",
"",
"## Summary",
f"- Total files: {result.total_files}",
f"- Successfully deleted: {result.successful_deletes}",
f"- Failed: {result.failed_deletes}",
]
if result.deleted_files:
result_lines.extend(["", "## Deleted Files"])
for file_path in result.deleted_files[:10]: # Show first 10
result_lines.append(f"- `{file_path}`")
if len(result.deleted_files) > 10:
result_lines.append(f"- ... and {len(result.deleted_files) - 10} more")
if result.errors: # pragma: no cover
result_lines.extend(["", "## Errors"])
for error in result.errors[:5]: # Show first 5 errors
result_lines.append(f"- `{error.path}`: {error.error}")
if len(result.errors) > 5:
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
logger.info(
f"Directory delete completed: {identifier}, "
f"deleted={result.successful_deletes}, failed={result.failed_deletes}"
)
return "\n".join(result_lines)
except Exception as e: # pragma: no cover
logger.error(f"Directory delete failed for '{identifier}': {e}")
return f"""# Directory Delete Failed
Error deleting directory '{identifier}': {str(e)}
## Troubleshooting:
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
2. **Check for permission issues**: Ensure you have delete access to the project
3. **Try individual deletes**: Delete files one at a time if bulk delete fails
## Alternative approach:
```
# List directory contents first
list_directory("{identifier}")
# Then delete individual files
delete_note("path/to/file.md")
```"""
# Handle single note deletes
try:
# Resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(identifier)
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
def _format_error_response(
@@ -213,7 +212,6 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
track_mcp_tool("edit_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -63,7 +62,6 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
track_mcp_tool("list_directory")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
+92 -13
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -343,34 +342,41 @@ delete_note("{identifier}")
@mcp.tool(
description="Move a note to a new location, updating database and maintaining links.",
description="Move a note or directory to a new location, updating database and maintaining links.",
)
async def move_note(
identifier: str,
destination_path: str,
is_directory: bool = False,
project: Optional[str] = None,
context: Context | None = None,
) -> str:
"""Move a note to a new file location within the same project.
"""Move a note or directory to a new location within the same project.
Moves a note from one location to another within the project, updating all
database references and maintaining semantic content. Uses stateless architecture -
project parameter optional with server resolution.
Moves a note or directory from one location to another within the project,
updating all database references and maintaining semantic content. Uses stateless
architecture - project parameter optional with server resolution.
Args:
identifier: Exact entity identifier (title, permalink, or memory:// URL).
identifier: For files: exact entity identifier (title, permalink, or memory:// URL).
For directories: the directory path (e.g., "docs", "projects/2025").
Must be an exact match - fuzzy matching is not supported for move operations.
Use search_notes() or read_note() first to find the correct identifier if uncertain.
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
Use search_notes() or list_directory() first to find the correct path if uncertain.
destination_path: For files: new path relative to project root (e.g., "work/meetings/note.md")
For directories: new directory path (e.g., "archive/docs")
is_directory: If True, moves an entire directory and all its contents.
When True, identifier and destination_path should be directory paths
(without file extensions). Defaults to False.
project: Project name to move within. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
context: Optional FastMCP context for performance caching.
Returns:
Success message with move details and project information.
For directories, includes count of files moved and any errors.
Examples:
# Move to new folder (exact title match)
# Move a single note to new folder (exact title match)
move_note("My Note", "work/notes/my-note.md")
# Move by exact permalink
@@ -382,6 +388,12 @@ async def move_note(
# Explicit project specification
move_note("My Note", "work/notes/my-note.md", project="work-project")
# Move entire directory
move_note("docs", "archive/docs", is_directory=True)
# Move nested directory
move_note("projects/2024", "archive/projects/2024", is_directory=True)
# If uncertain about identifier, search first:
# search_notes("my note") # Find available notes
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
@@ -400,9 +412,10 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
track_mcp_tool("move_note")
async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
logger.debug(
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {project}"
)
active_project = await get_active_project(client, project, context)
@@ -428,7 +441,73 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
```"""
# Check for potential cross-project move attempts
# Handle directory moves
if is_directory:
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient
knowledge_client = KnowledgeClient(client, active_project.external_id)
try:
result = await knowledge_client.move_directory(identifier, destination_path)
# Build success message for directory move
result_lines = [
"# Directory Moved Successfully",
"",
f"**Source:** `{identifier}`",
f"**Destination:** `{destination_path}`",
"",
"## Summary",
f"- Total files: {result.total_files}",
f"- Successfully moved: {result.successful_moves}",
f"- Failed: {result.failed_moves}",
]
if result.moved_files:
result_lines.extend(["", "## Moved Files"])
for file_path in result.moved_files[:10]: # Show first 10
result_lines.append(f"- `{file_path}`")
if len(result.moved_files) > 10:
result_lines.append(f"- ... and {len(result.moved_files) - 10} more")
if result.errors: # pragma: no cover
result_lines.extend(["", "## Errors"])
for error in result.errors[:5]: # Show first 5 errors
result_lines.append(f"- `{error.path}`: {error.error}")
if len(result.errors) > 5:
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
logger.info(
f"Directory move completed: {identifier} -> {destination_path}, "
f"moved={result.successful_moves}, failed={result.failed_moves}"
)
return "\n".join(result_lines)
except Exception as e: # pragma: no cover
logger.error(f"Directory move failed for '{identifier}' to '{destination_path}': {e}")
return f"""# Directory Move Failed
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
## Troubleshooting:
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
2. **Check for conflicts**: The destination may already contain files
3. **Try individual moves**: Move files one at a time if bulk move fails
## Alternative approach:
```
# List directory contents first
list_directory("{identifier}")
# Then move individual files
move_note("path/to/file.md", "{destination_path}/file.md")
```"""
# Check for potential cross-project move attempts (file moves only)
cross_project_error = await _detect_cross_project_move_attempt(
client, identifier, destination_path, active_project.name
)
@@ -10,7 +10,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import generate_permalink
@@ -36,7 +35,6 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example:
list_memory_projects()
"""
track_mcp_tool("list_memory_projects")
async with get_client() as client:
if context: # pragma: no cover
await context.info("Listing all available projects")
@@ -92,7 +90,6 @@ async def create_memory_project(
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
track_mcp_tool("create_memory_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -151,7 +148,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
track_mcp_tool("delete_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -20,7 +20,6 @@ from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -201,7 +200,6 @@ async def read_content(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal
"""
track_mcp_tool("read_content")
logger.info("Reading file", path=path, project=project)
async with get_client() as client:
-2
View File
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -77,7 +76,6 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
track_mcp_tool("read_note")
async with get_client() as client:
# Get and validate the project
active_project = await get_active_project(client, project, context)
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -100,7 +99,6 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
track_mcp_tool("recent_activity")
async with get_client() as client:
# Build common parameters for API calls
params = {
+1 -3
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -330,7 +329,6 @@ async def search_notes(
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
track_mcp_tool("search_notes")
# Avoid mutable-default-argument footguns. Treat None as "no filter".
types = types or []
entity_types = entity_types or []
@@ -347,7 +345,7 @@ async def search_notes(
search_query.permalink_match = query
elif search_type == "permalink":
search_query.permalink = query
else:
else: # pragma: no cover
search_query.text = query # Default to text search
# Add optional filters if provided (empty lists are treated as no filter)
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -55,7 +54,6 @@ async def view_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal
"""
track_mcp_tool("view_note")
logger.info(f"Viewing note: {identifier} in project: {project}")
# Call the existing read_note logic
+18 -20
View File
@@ -7,7 +7,6 @@ from loguru import logger
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from fastmcp import Context
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags, validate_project_path
@@ -22,7 +21,7 @@ TagType = Union[List[str], str, None]
async def write_note(
title: str,
content: str,
folder: str,
directory: str,
project: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
@@ -58,9 +57,9 @@ async def write_note(
Args:
title: The title of the note
content: Markdown content for the note, can include observations and relations
folder: Folder path relative to project root where the file should be saved.
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
Examples: "notes", "projects/2025", "research/ml", "/" (root)
directory: Directory path relative to project root where the file should be saved.
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
Examples: "notes", "projects/2025", "research/ml", "/" (root)
project: Project name to write to. Optional - server will resolve using the
hierarchy above. If unknown, use list_memory_projects() to discover
available projects.
@@ -89,7 +88,7 @@ async def write_note(
write_note(
project="my-research",
title="Meeting Notes",
folder="meetings",
directory="meetings",
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
)
@@ -97,46 +96,45 @@ async def write_note(
write_note(
project="work-project",
title="API Design",
folder="specs",
directory="specs",
content="# REST API Specification\\n\\n- implements [[Authentication]]",
tags=["api", "design"],
note_type="guide"
)
# Update existing note (same title/folder)
# Update existing note (same title/directory)
write_note(
project="my-research",
title="Meeting Notes",
folder="meetings",
directory="meetings",
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
)
Raises:
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If folder path attempts path traversal
SecurityError: If directory path attempts path traversal
"""
track_mcp_tool("write_note")
async with get_client() as client:
logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
f"MCP tool call tool=write_note project={project} directory={directory}, title={title}, tags={tags}"
)
# Get and validate the project (supports optional project parameter)
active_project = await get_active_project(client, project, context)
# Normalize "/" to empty string for root folder (must happen before validation)
if folder == "/":
folder = ""
# Normalize "/" to empty string for root directory (must happen before validation)
if directory == "/":
directory = ""
# Validate folder path to prevent path traversal attacks
# Validate directory path to prevent path traversal attacks
project_path = active_project.home
if folder and not validate_project_path(folder, project_path):
if directory and not validate_project_path(directory, project_path):
logger.warning(
"Attempted path traversal attack blocked",
folder=folder,
directory=directory,
project=active_project.name,
)
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
# Process tags using the helper function
tag_list = parse_tags(tags)
@@ -144,7 +142,7 @@ async def write_note(
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
directory=directory,
entity_type=note_type,
content_type="text/markdown",
content=content,
@@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any
from loguru import logger
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
@@ -69,12 +69,21 @@ class EntityRepository(Repository[Entity]):
return await self.find_one(query)
async def get_by_title(self, title: str) -> Sequence[Entity]:
"""Get entity by title.
"""Get entities by title, ordered by shortest path first.
When multiple entities share the same title (in different folders),
returns them ordered by file_path length then alphabetically.
This provides "shortest path" resolution for duplicate titles.
Args:
title: Title of the entity to find
"""
query = self.select().where(Entity.title == title).options(*self.get_load_options())
query = (
self.select()
.where(Entity.title == title)
.order_by(func.length(Entity.file_path), Entity.file_path)
.options(*self.get_load_options())
)
result = await self.execute_query(query)
return list(result.scalars().all())
@@ -88,7 +88,7 @@ class ProjectRepository(Repository[Project]):
async def get_default_project(self) -> Optional[Project]:
"""Get the default project (the one marked as is_default=True)."""
query = self.select().where(Project.is_default.is_not(None))
query = self.select().where(Project.is_default.is_(True))
return await self.find_one(query)
async def get_active_projects(self) -> Sequence[Project]:
+7 -5
View File
@@ -24,7 +24,7 @@ from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
from basic_memory.config import ConfigManager
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_directory
from basic_memory.utils import generate_permalink
@@ -227,7 +227,7 @@ class Entity(BaseModel):
title: str
content: Optional[str] = None
folder: str
directory: str
entity_type: EntityType = "note"
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
content_type: ContentType = Field(
@@ -237,7 +237,7 @@ class Entity(BaseModel):
)
def __init__(self, **data):
data["folder"] = sanitize_for_folder(data.get("folder", ""))
data["directory"] = sanitize_for_directory(data.get("directory", ""))
super().__init__(**data)
@property
@@ -272,10 +272,12 @@ class Entity(BaseModel):
safe_title = self.safe_title
if self.content_type == "text/markdown":
return (
os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
os.path.join(self.directory, f"{safe_title}.md")
if self.directory
else f"{safe_title}.md"
)
else:
return os.path.join(self.folder, safe_title) if self.folder else safe_title
return os.path.join(self.directory, safe_title) if self.directory else safe_title
@property
def permalink(self) -> Optional[Permalink]:
+24
View File
@@ -110,3 +110,27 @@ class MoveEntityRequest(BaseModel):
if not v.strip():
raise ValueError("destination_path cannot be empty or whitespace only")
return v.strip()
class MoveDirectoryRequest(BaseModel):
"""Request schema for moving an entire directory to a new location.
This moves all entities within a directory to a new location while
maintaining project consistency and updating database references.
"""
source_directory: Annotated[str, MinLen(1), MaxLen(500)]
destination_directory: Annotated[str, MinLen(1), MaxLen(500)]
project: Optional[str] = None
@field_validator("source_directory", "destination_directory")
@classmethod
def validate_directory_path(cls, v):
"""Ensure directory path is relative and valid."""
if v.startswith("/"): # pragma: no cover
raise ValueError("directory path must be relative, not absolute")
if ".." in v: # pragma: no cover
raise ValueError("directory path cannot contain '..' path components")
if not v.strip(): # pragma: no cover
raise ValueError("directory path cannot be empty or whitespace only")
return v.strip()
+68
View File
@@ -284,3 +284,71 @@ class DeleteEntitiesResponse(SQLAlchemyModel):
"""
deleted: bool
class DirectoryMoveError(BaseModel):
"""Error details for a failed file move within a directory move operation."""
path: str
error: str
class DirectoryDeleteError(BaseModel):
"""Error details for a failed file delete within a directory delete operation."""
path: str
error: str
class DirectoryMoveResult(SQLAlchemyModel):
"""Response schema for directory move operations.
Returns detailed results of moving all files within a directory,
including counts and any errors encountered.
Example Response:
{
"total_files": 5,
"successful_moves": 5,
"failed_moves": 0,
"moved_files": [
"docs/file1.md",
"docs/file2.md",
"docs/subdir/file3.md"
],
"errors": []
}
"""
total_files: int
successful_moves: int
failed_moves: int
moved_files: List[str] # List of file paths that were moved
errors: List[DirectoryMoveError] # List of errors for failed moves
class DirectoryDeleteResult(SQLAlchemyModel):
"""Response schema for directory delete operations.
Returns detailed results of deleting all files within a directory,
including counts and any errors encountered.
Example Response:
{
"total_files": 5,
"successful_deletes": 5,
"failed_deletes": 0,
"deleted_files": [
"docs/file1.md",
"docs/file2.md",
"docs/subdir/file3.md"
],
"errors": []
}
"""
total_files: int
successful_deletes: int
failed_deletes: int
deleted_files: List[str] # List of file paths that were deleted
errors: List[DirectoryDeleteError] # List of errors for failed deletes
+4
View File
@@ -5,6 +5,8 @@ from basic_memory.schemas.v2.entity import (
EntityResolveResponse,
EntityResponseV2,
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
ProjectResolveRequest,
ProjectResolveResponse,
)
@@ -19,6 +21,8 @@ __all__ = [
"EntityResolveResponse",
"EntityResponseV2",
"MoveEntityRequestV2",
"MoveDirectoryRequestV2",
"DeleteDirectoryRequestV2",
"ProjectResolveRequest",
"ProjectResolveResponse",
"CreateResourceRequest",
+48
View File
@@ -15,6 +15,9 @@ class EntityResolveRequest(BaseModel):
- Permalinks (e.g., "specs/search")
- Titles (e.g., "Search Specification")
- File paths (e.g., "specs/search.md")
When source_path is provided, resolution prefers notes closer to the source
(context-aware resolution for duplicate titles).
"""
identifier: str = Field(
@@ -23,6 +26,15 @@ class EntityResolveRequest(BaseModel):
min_length=1,
max_length=500,
)
source_path: Optional[str] = Field(
None,
description="Path of the source file containing the link (for context-aware resolution)",
max_length=500,
)
strict: bool = Field(
False,
description="If True, only exact matches are allowed (no fuzzy search fallback)",
)
class EntityResolveResponse(BaseModel):
@@ -56,6 +68,42 @@ class MoveEntityRequestV2(BaseModel):
)
class MoveDirectoryRequestV2(BaseModel):
"""V2 request schema for moving an entire directory to a new location.
This moves all entities within a source directory to a destination directory
while maintaining project consistency and updating database references.
"""
source_directory: str = Field(
...,
description="Source directory path (relative to project root)",
min_length=1,
max_length=500,
)
destination_directory: str = Field(
...,
description="Destination directory path (relative to project root)",
min_length=1,
max_length=500,
)
class DeleteDirectoryRequestV2(BaseModel):
"""V2 request schema for deleting all entities in a directory.
This deletes all entities within a directory, removing them from the
database and file system.
"""
directory: str = Field(
...,
description="Directory path to delete (relative to project root)",
min_length=1,
max_length=500,
)
class EntityResponseV2(BaseModel):
"""V2 entity response with external_id as the primary API identifier.
+175 -1
View File
@@ -26,6 +26,12 @@ from basic_memory.repository import ObservationRepository, RelationRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.schemas.base import Permalink
from basic_memory.schemas.response import (
DirectoryMoveResult,
DirectoryMoveError,
DirectoryDeleteResult,
DirectoryDeleteError,
)
from basic_memory.services import BaseService, FileService
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
from basic_memory.services.link_resolver import LinkResolver
@@ -191,7 +197,7 @@ class EntityService(BaseService[EntityModel]):
if await self.file_service.exists(file_path):
raise EntityCreationError(
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
)
# Parse content frontmatter to check for user-specified permalink and entity_type
@@ -862,3 +868,171 @@ class EntityService(BaseService[EntityModel]):
# Re-raise the original error with context
raise ValueError(f"Move failed: {str(e)}") from e
async def move_directory(
self,
source_directory: str,
destination_directory: str,
project_config: ProjectConfig,
app_config: BasicMemoryConfig,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
This operation moves all files within a source directory to a destination
directory, updating database records and search indexes. The operation
tracks successes and failures individually to provide detailed feedback.
Args:
source_directory: Source directory path relative to project root
destination_directory: Destination directory path relative to project root
project_config: Project configuration for file operations
app_config: App configuration for permalink update settings
Returns:
DirectoryMoveResult with counts and details of moved files
Raises:
ValueError: If source directory is empty or destination conflicts exist
"""
logger.info(f"Moving directory: {source_directory} -> {destination_directory}")
# Normalize directory paths (remove trailing slashes)
source_directory = source_directory.strip("/")
destination_directory = destination_directory.strip("/")
# Find all entities in the source directory
entities = await self.repository.find_by_directory_prefix(source_directory)
if not entities:
logger.warning(f"No entities found in directory: {source_directory}")
return DirectoryMoveResult(
total_files=0,
successful_moves=0,
failed_moves=0,
moved_files=[],
errors=[],
)
# Track results
moved_files: list[str] = []
errors: list[DirectoryMoveError] = []
successful_moves = 0
failed_moves = 0
# Process each entity
for entity in entities:
try:
# Calculate new path by replacing source prefix with destination
old_path = entity.file_path
# Replace only the first occurrence of the source directory prefix
if old_path.startswith(f"{source_directory}/"):
new_path = old_path.replace(f"{source_directory}/", f"{destination_directory}/", 1)
else: # pragma: no cover
# Entity is directly in the source directory (shouldn't happen with prefix match)
new_path = f"{destination_directory}/{old_path}"
# Move the individual entity
await self.move_entity(
identifier=entity.file_path,
destination_path=new_path,
project_config=project_config,
app_config=app_config,
)
moved_files.append(new_path)
successful_moves += 1
logger.debug(f"Moved entity: {old_path} -> {new_path}")
except Exception as e: # pragma: no cover
failed_moves += 1
errors.append(DirectoryMoveError(path=entity.file_path, error=str(e)))
logger.error(f"Failed to move entity {entity.file_path}: {e}")
logger.info(
f"Directory move complete: {successful_moves} succeeded, {failed_moves} failed "
f"(source={source_directory}, dest={destination_directory})"
)
return DirectoryMoveResult(
total_files=len(entities),
successful_moves=successful_moves,
failed_moves=failed_moves,
moved_files=moved_files,
errors=errors,
)
async def delete_directory(
self,
directory: str,
) -> DirectoryDeleteResult:
"""Delete all entities in a directory.
This operation deletes all files within a directory, updating database
records and search indexes. The operation tracks successes and failures
individually to provide detailed feedback.
Args:
directory: Directory path relative to project root
Returns:
DirectoryDeleteResult with counts and details of deleted files
"""
logger.info(f"Deleting directory: {directory}")
# Normalize directory path (remove trailing slashes)
directory = directory.strip("/")
# Find all entities in the directory
entities = await self.repository.find_by_directory_prefix(directory)
if not entities:
logger.warning(f"No entities found in directory: {directory}")
return DirectoryDeleteResult(
total_files=0,
successful_deletes=0,
failed_deletes=0,
deleted_files=[],
errors=[],
)
# Track results
deleted_files: list[str] = []
errors: list[DirectoryDeleteError] = []
successful_deletes = 0
failed_deletes = 0
# Process each entity
for entity in entities:
try:
file_path = entity.file_path
# Delete the entity (this handles file deletion and database cleanup)
deleted = await self.delete_entity(entity.id)
if deleted:
deleted_files.append(file_path)
successful_deletes += 1
logger.debug(f"Deleted entity: {file_path}")
else: # pragma: no cover
failed_deletes += 1
errors.append(DirectoryDeleteError(path=file_path, error="Delete returned False"))
logger.warning(f"Delete returned False for entity: {file_path}")
except Exception as e: # pragma: no cover
failed_deletes += 1
errors.append(DirectoryDeleteError(path=entity.file_path, error=str(e)))
logger.error(f"Failed to delete entity {entity.file_path}: {e}")
logger.info(
f"Directory delete complete: {successful_deletes} succeeded, {failed_deletes} failed "
f"(directory={directory})"
)
return DirectoryDeleteResult(
total_files=len(entities),
successful_deletes=successful_deletes,
failed_deletes=failed_deletes,
deleted_files=deleted_files,
errors=errors,
)
+134 -4
View File
@@ -28,7 +28,11 @@ class LinkResolver:
self.search_service = search_service
async def resolve_link(
self, link_text: str, use_search: bool = True, strict: bool = False
self,
link_text: str,
use_search: bool = True,
strict: bool = False,
source_path: Optional[str] = None,
) -> Optional[Entity]:
"""Resolve a markdown link to a permalink.
@@ -36,12 +40,69 @@ class LinkResolver:
link_text: The link text to resolve
use_search: Whether to use search-based fuzzy matching as fallback
strict: If True, only exact matches are allowed (no fuzzy search fallback)
source_path: Optional path of the source file containing the link.
Used to prefer notes closer to the source (context-aware resolution).
"""
logger.trace(f"Resolving link: {link_text}")
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
# Clean link text and extract any alias
clean_text, alias = self._normalize_link_text(link_text)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
# for cross-platform compatibility. See entity_repository.py which normalizes
# paths using Path().as_posix(). This allows consistent path operations here.
# --- Relative Path Resolution ---
# Trigger: source_path is provided AND link contains "/"
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
if source_path and "/" in clean_text:
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
if source_folder:
# Construct relative path from source folder
relative_path = f"{source_folder}/{clean_text}"
# Try with .md extension
if not relative_path.endswith(".md"):
relative_path_md = f"{relative_path}.md"
entity = await self.entity_repository.get_by_file_path(relative_path_md)
if entity:
return entity
# Try as-is (already has extension or is a permalink)
entity = await self.entity_repository.get_by_file_path(relative_path)
if entity:
return entity
# When source_path is provided, use context-aware resolution:
# Check both permalink and title matches, prefer closest to source.
# Example: [[testing]] from folder/note.md prefers folder/testing.md
# over a root testing.md with permalink "testing".
if source_path:
# Gather all potential matches
candidates: list[Entity] = []
# Check permalink match
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
if permalink_entity:
candidates.append(permalink_entity)
# Check title matches
title_entities = await self.entity_repository.get_by_title(clean_text)
for entity in title_entities:
# Avoid duplicates (permalink match might also be in title matches)
if entity.id not in [c.id for c in candidates]:
candidates.append(entity)
if candidates:
if len(candidates) == 1:
return candidates[0]
else:
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)
# Standard resolution (no source context): permalink first, then title
# 1. Try exact permalink match first (most efficient)
entity = await self.entity_repository.get_by_permalink(clean_text)
if entity:
@@ -51,7 +112,7 @@ class LinkResolver:
# 2. Try exact title match
found = await self.entity_repository.get_by_title(clean_text)
if found:
# Return first match if there are duplicates (consistent behavior)
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
@@ -108,7 +169,7 @@ class LinkResolver:
if text.startswith("[[") and text.endswith("]]"):
text = text[2:-2]
# Handle Obsidian-style aliases (format: [[actual|alias]])
# Handle wiki link aliases (format: [[actual|alias]])
alias = None
if "|" in text:
text, alias = text.split("|", 1)
@@ -119,3 +180,72 @@ class LinkResolver:
text = text.strip()
return text, alias
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
"""Find the entity closest to the source file path.
Context-aware resolution: prefer notes in the same folder or closer in hierarchy.
Proximity Scoring Algorithm:
- Priority 0: Same folder as source (best match)
- Priority 1-N: Ancestor folders (N = levels up from source)
- Priority 100+N: Descendant folders (N = levels down, deprioritized)
- Priority 1000: Completely unrelated paths (least preferred)
- Ties are broken by shortest absolute path (consistent behavior)
Args:
entities: List of entities with the same title
source_path: Path of the file containing the link
Returns:
The entity closest to the source path
"""
# Extract source folder (everything before the last /)
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
def path_proximity(entity: Entity) -> Tuple[int, int]:
"""Return (proximity_score, path_length) for sorting.
Lower is better for both values.
"""
entity_path = entity.file_path
entity_folder = entity_path.rsplit("/", 1)[0] if "/" in entity_path else ""
# Trigger: entity is in the same folder as source
# Why: same-folder notes are most contextually relevant
# Outcome: priority = 0 (best), ties broken by shortest path
if entity_folder == source_folder:
return (0, len(entity_path))
# Trigger: entity is in an ancestor folder of source
# e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor
# Why: ancestors are contextually relevant (shared parent context)
# Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred
if source_folder.startswith(entity_folder + "/") if entity_folder else source_folder:
# Count how many levels up
if entity_folder:
levels_up = source_folder.count("/") - entity_folder.count("/")
else:
# Root level
levels_up = source_folder.count("/") + 1
return (levels_up, len(entity_path))
# Trigger: entity is in a descendant folder of source
# e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant
# Why: descendants are less contextually relevant than ancestors
# Outcome: priority = 100 + levels_down, significantly deprioritized
if entity_folder.startswith(source_folder + "/") if source_folder else entity_folder:
if source_folder:
levels_down = entity_folder.count("/") - source_folder.count("/")
else:
# Source is at root
levels_down = entity_folder.count("/") + 1
return (100 + levels_down, len(entity_path))
# Trigger: entity is in a completely unrelated path
# Why: no folder relationship means minimal contextual relevance
# Outcome: priority = 1000, only selected if no related paths exist
return (1000, len(entity_path))
# Sort by proximity (lower is better), then by path length (shorter is better)
return min(entities, key=path_proximity)
+7 -2
View File
@@ -241,8 +241,13 @@ class ProjectService:
project_path = project.path
# Check if project is default (in cloud mode, check database; in local mode, check config)
if project.is_default or name == self.config_manager.config.default_project:
# Check if project is default
# In cloud mode: database is source of truth
# In local mode: also check config file
is_default = project.is_default
if not self.config_manager.config.cloud_mode:
is_default = is_default or name == self.config_manager.config.default_project
if is_default:
raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
# Remove from config if it exists there (may not exist in cloud mode)
-320
View File
@@ -1,320 +0,0 @@
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
This module implements privacy-respecting usage analytics following the Homebrew model:
- Telemetry is ON by default
- Users can easily opt out: `bm telemetry disable`
- First run shows a one-time notice (not a prompt)
- Only anonymous data is collected (random UUID, no personal info)
What we collect:
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized, no file paths or personal data)
What we NEVER collect:
- Note content, file names, or paths
- Personal information
- IP addresses (OpenPanel doesn't store these)
Documentation: https://basicmemory.com/telemetry
"""
from __future__ import annotations
import platform
import re
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from basic_memory import __version__
if TYPE_CHECKING:
from openpanel import OpenPanel
# --- Configuration ---
# OpenPanel credentials (write-only, safe to embed in client code)
# These can only send events to our dashboard, not read any data
OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
TELEMETRY_NOTICE = f"""
Basic Memory collects anonymous usage statistics to help improve the software.
This includes: version, OS, feature usage, and errors. No personal data or note content.
To opt out: bm telemetry disable
Details: {TELEMETRY_DOCS_URL}
"""
# --- Module State ---
_client: OpenPanel | None = None
_initialized: bool = False
_telemetry_enabled: bool | None = None # Cached to avoid repeated config reads
# --- Telemetry State ---
def _is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled (cached).
Returns False if:
- User disabled via `bm telemetry disable`
- DO_NOT_TRACK environment variable is set
- Running in test environment
"""
global _telemetry_enabled
if _telemetry_enabled is None:
from basic_memory.config import ConfigManager
config = ConfigManager().config
_telemetry_enabled = config.telemetry_enabled and not config.is_test_env
return _telemetry_enabled
# --- Installation ID ---
def get_install_id() -> str:
"""Get or create anonymous installation ID.
Creates a random UUID on first run and stores it locally.
User can delete ~/.basic-memory/.install_id to reset.
"""
id_file = Path.home() / ".basic-memory" / ".install_id"
if id_file.exists():
return id_file.read_text().strip()
install_id = str(uuid.uuid4())
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text(install_id)
return install_id
# --- Client Management ---
def _get_client() -> OpenPanel | None:
"""Get or create the OpenPanel client (singleton).
Lazily initializes the client with global properties.
Returns None if telemetry is disabled (avoids creating background thread).
"""
global _client, _initialized
# Trigger: telemetry disabled via config, env var, or test mode
# Why: OpenPanel creates a background thread even when disabled=True,
# which can cause hangs on Python 3.14 during thread shutdown
# Outcome: return None early, no OpenPanel client or thread created
if not _is_telemetry_enabled():
return None
if _client is None:
# Defer import to avoid creating background thread when telemetry disabled
from openpanel import OpenPanel
_client = OpenPanel(
client_id=OPENPANEL_CLIENT_ID,
client_secret=OPENPANEL_CLIENT_SECRET,
)
if not _initialized:
install_id = get_install_id()
# Set profile ID for OpenPanel (required for API to accept events)
_client.identify(install_id)
# Set global properties that go with every event
_client.set_global_properties(
{
"app_version": __version__,
"python_version": platform.python_version(),
"os": platform.system().lower(),
"arch": platform.machine(),
"install_id": install_id,
"source": "foss",
}
)
_initialized = True
return _client
def reset_client() -> None:
"""Reset the telemetry client (for testing or after config changes)."""
global _client, _initialized, _telemetry_enabled
_client = None
_initialized = False
_telemetry_enabled = None
def shutdown_telemetry() -> None:
"""Shutdown the telemetry client, stopping its background thread.
Call this on application exit to ensure clean shutdown.
The OpenPanel client creates a background thread with an event loop
that needs to be stopped to avoid hangs on Python 3.14+.
"""
import gc
import io
import sys
global _client
if _client is not None:
try:
# Suppress "Task was destroyed but it is pending!" warnings
# These occur when we stop the event loop with pending HTTP requests,
# which is expected during shutdown. The message is printed directly
# to stderr by asyncio.Task.__del__(), so we redirect stderr temporarily.
# We also force garbage collection to ensure the warning happens
# while stderr is still redirected.
stderr_backup = sys.stderr
sys.stderr = io.StringIO()
try:
# OpenPanel._cleanup stops the event loop and joins the thread
_client._cleanup()
_client = None
# Force garbage collection to trigger Task.__del__ while stderr is redirected
gc.collect()
finally:
sys.stderr = stderr_backup
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry shutdown failed: {e}")
finally:
_client = None
# --- Event Tracking ---
def track(event: str, properties: dict[str, Any] | None = None) -> None:
"""Track an event. Fire-and-forget, never raises.
Args:
event: Event name (e.g., "app_started", "mcp_tool_called")
properties: Optional event properties
"""
# Constraint: telemetry must never break the application
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
try:
client = _get_client()
if client is not None:
client.track(event, properties or {})
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
# --- First-Run Notice ---
def show_notice_if_needed() -> None:
"""Show one-time telemetry notice (Homebrew style).
Only shows if:
- Telemetry is enabled
- Notice hasn't been shown before
After showing, marks the notice as shown in config.
"""
from basic_memory.config import ConfigManager
config_manager = ConfigManager()
config = config_manager.config
if config.telemetry_enabled and not config.telemetry_notice_shown:
from rich.console import Console
from rich.panel import Panel
# Print to stderr so it doesn't interfere with command output
console = Console(stderr=True)
console.print(
Panel(
TELEMETRY_NOTICE.strip(),
title="[dim]Telemetry Notice[/dim]",
border_style="dim",
expand=False,
)
)
# Mark as shown so we don't show again
config.telemetry_notice_shown = True
config_manager.save_config(config)
# --- Convenience Functions ---
def track_app_started(mode: str) -> None:
"""Track app startup.
Args:
mode: "cli" or "mcp"
"""
track("app_started", {"mode": mode})
def track_mcp_tool(tool_name: str) -> None:
"""Track MCP tool usage.
Args:
tool_name: Name of the tool (e.g., "write_note", "search_notes")
"""
track("mcp_tool_called", {"tool": tool_name})
def track_cli_command(command: str) -> None:
"""Track CLI command usage.
Args:
command: Command name (e.g., "sync", "import claude")
"""
track("cli_command", {"command": command})
def track_sync_completed(entity_count: int, duration_ms: int) -> None:
"""Track sync completion.
Args:
entity_count: Number of entities synced
duration_ms: Duration in milliseconds
"""
track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
def track_import_completed(source: str, count: int) -> None:
"""Track import completion.
Args:
source: Import source (e.g., "claude", "chatgpt")
count: Number of items imported
"""
track("import_completed", {"source": source, "count": count})
def track_error(error_type: str, message: str) -> None:
"""Track an error (sanitized).
Args:
error_type: Exception class name
message: Error message (will be sanitized to remove file paths)
"""
if not message:
track("error", {"type": error_type, "message": ""})
return
# Sanitize file paths to prevent leaking user directory structure
# Unix paths: /Users/name/file.py, /home/user/notes/doc.md
sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
# Windows paths: C:\Users\name\file.py, D:\projects\doc.md
sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
# Truncate to avoid sending too much data
track("error", {"type": error_type, "message": sanitized[:200]})
+181
View File
@@ -0,0 +1,181 @@
"""Integration tests for CLI routing flags (--local/--cloud).
These tests verify that the --local and --cloud flags work correctly
across CLI commands, and that the MCP command forces local routing.
Note: Environment variable behavior during command execution is tested
in unit tests (tests/cli/test_routing.py) which can properly monkeypatch
the modules before they are imported. These integration tests focus on
CLI behavior: flag acceptance and error handling.
"""
import os
import pytest
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
class TestRoutingFlagsValidation:
"""Tests for --local/--cloud flag validation.
These tests verify that using both --local and --cloud together
produces an appropriate error message.
"""
def test_status_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["status", "--local", "--cloud"])
# Exit code can be 1 or 2 depending on how typer handles the exception
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_project_list_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["project", "list", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_search_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_read_note_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["tool", "read-note", "test", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_build_context_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(
cli_app, ["tool", "build-context", "memory://test", "--local", "--cloud"]
)
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
class TestMcpCommandForcesLocal:
"""Tests that the MCP command forces local routing."""
def test_mcp_sets_force_local_env(self, monkeypatch):
"""MCP command should set BASIC_MEMORY_FORCE_LOCAL before server starts."""
# Track what environment variable was set
env_set_value = []
# Mock the MCP server run to capture env state without actually starting server
import basic_memory.cli.commands.mcp as mcp_mod
def mock_run(*args, **kwargs):
env_set_value.append(os.environ.get("BASIC_MEMORY_FORCE_LOCAL"))
# Don't actually start the server
raise SystemExit(0)
# Get the actual mcp_server from the module
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
# Also mock init_mcp_logging to avoid file operations
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
runner.invoke(cli_app, ["mcp"])
# Environment variable should have been set to "true"
assert len(env_set_value) == 1
assert env_set_value[0] == "true"
class TestToolCommandsAcceptFlags:
"""Tests that tool commands accept routing flags without parsing errors."""
@pytest.mark.parametrize(
"command,args",
[
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
)
def test_tool_commands_accept_local_flag(self, command, args, app_config):
"""Tool commands should accept --local flag without parsing error."""
full_args = ["tool", command] + args + ["--local"]
result = runner.invoke(cli_app, full_args)
# Should not fail due to flag parsing (No such option error)
assert "No such option: --local" not in result.output
@pytest.mark.parametrize(
"command,args",
[
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
)
def test_tool_commands_accept_cloud_flag(self, command, args, app_config):
"""Tool commands should accept --cloud flag without parsing error."""
full_args = ["tool", command] + args + ["--cloud"]
result = runner.invoke(cli_app, full_args)
# Should not fail due to flag parsing (No such option error)
assert "No such option: --cloud" not in result.output
class TestProjectCommandsAcceptFlags:
"""Tests that project commands accept routing flags without parsing errors."""
def test_project_list_accepts_local_flag(self, app_config):
"""project list should accept --local flag."""
result = runner.invoke(cli_app, ["project", "list", "--local"])
assert "No such option: --local" not in result.output
def test_project_list_accepts_cloud_flag(self, app_config):
"""project list should accept --cloud flag."""
result = runner.invoke(cli_app, ["project", "list", "--cloud"])
assert "No such option: --cloud" not in result.output
def test_project_info_accepts_local_flag(self, app_config):
"""project info should accept --local flag."""
result = runner.invoke(cli_app, ["project", "info", "--local"])
assert "No such option: --local" not in result.output
def test_project_info_accepts_cloud_flag(self, app_config):
"""project info should accept --cloud flag."""
result = runner.invoke(cli_app, ["project", "info", "--cloud"])
assert "No such option: --cloud" not in result.output
def test_project_default_accepts_local_flag(self, app_config):
"""project default should accept --local flag."""
result = runner.invoke(cli_app, ["project", "default", "test", "--local"])
assert "No such option: --local" not in result.output
def test_project_sync_config_accepts_local_flag(self, app_config):
"""project sync-config should accept --local flag."""
result = runner.invoke(cli_app, ["project", "sync-config", "test", "--local"])
assert "No such option: --local" not in result.output
def test_project_move_accepts_local_flag(self, app_config):
"""project move should accept --local flag."""
result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--local"])
assert "No such option: --local" not in result.output
class TestStatusCommandAcceptsFlags:
"""Tests that status command accepts routing flags."""
def test_status_accepts_local_flag(self, app_config):
"""status should accept --local flag."""
result = runner.invoke(cli_app, ["status", "--local"])
assert "No such option: --local" not in result.output
def test_status_accepts_cloud_flag(self, app_config):
"""status should accept --cloud flag."""
result = runner.invoke(cli_app, ["status", "--cloud"])
assert "No such option: --cloud" not in result.output
@@ -15,7 +15,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Parent Entity",
"folder": "testing",
"directory": "testing",
"content": "# Parent Entity\n\nMain entity for testing underscore relations.",
"tags": "test,parent",
},
@@ -27,7 +27,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Child with Underscore",
"folder": "testing",
"directory": "testing",
"content": """# Child with Underscore
- part_of [[Parent Entity]]
@@ -42,7 +42,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Child with Hyphen",
"folder": "testing",
"directory": "testing",
"content": """# Child with Hyphen
- part-of [[Parent Entity]]
@@ -124,7 +124,7 @@ async def test_build_context_complex_underscore_paths(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "workflow_manager_agent",
"folder": "specs",
"directory": "specs",
"content": """# Workflow Manager Agent
Specification for the workflow manager agent.
@@ -138,7 +138,7 @@ Specification for the workflow manager agent.
{
"project": test_project.name,
"title": "task_parser",
"folder": "components",
"directory": "components",
"content": """# Task Parser
- part_of [[workflow_manager_agent]]
@@ -15,7 +15,7 @@ async def test_build_context_valid_urls(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "URL Validation Test",
"folder": "testing",
"directory": "testing",
"content": "# URL Validation Test\n\nThis note tests URL validation.",
"tags": "test,validation",
},
@@ -169,7 +169,7 @@ async def test_build_context_pattern_matching_works(mcp_server, app, test_projec
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": content,
},
)
+10 -10
View File
@@ -36,7 +36,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Machine Learning Fundamentals",
"folder": "ai",
"directory": "ai",
"content": (
"# Machine Learning Fundamentals\n\nIntroduction to ML concepts and algorithms."
),
@@ -49,7 +49,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Deep Learning with PyTorch",
"folder": "ai",
"directory": "ai",
"content": (
"# Deep Learning with PyTorch\n\n"
"Building neural networks using PyTorch framework."
@@ -63,7 +63,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Data Visualization Guide",
"folder": "data",
"directory": "data",
"content": (
"# Data Visualization Guide\n\nCreating charts and graphs for data analysis."
),
@@ -127,7 +127,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Python Web Frameworks",
"folder": "dev",
"directory": "dev",
"content": (
"# Python Web Frameworks\n\nComparing Django and Flask for web development."
),
@@ -140,7 +140,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "JavaScript Frameworks",
"folder": "dev",
"directory": "dev",
"content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.",
"tags": "javascript,web,frameworks",
},
@@ -191,7 +191,7 @@ def my_decorator(func):
{
"project": test_project.name,
"title": "Advanced Python Techniques",
"folder": "programming",
"directory": "programming",
"content": note_content,
"tags": "python,advanced,programming",
},
@@ -231,7 +231,7 @@ async def test_chatgpt_fetch_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Test Document",
"folder": "test",
"directory": "test",
"content": "# Test Document\n\nThis is test content for permalink fetching.",
"tags": "test",
},
@@ -301,7 +301,7 @@ async def test_chatgpt_fetch_with_empty_title(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "untitled-note",
"folder": "misc",
"directory": "misc",
"content": "This is content without a markdown header.\n\nJust plain text.",
"tags": "misc",
},
@@ -337,7 +337,7 @@ async def test_chatgpt_search_pagination_default(mcp_server, app, test_project):
{
"project": test_project.name,
"title": f"Test Note {i}",
"folder": "bulk",
"directory": "bulk",
"content": f"# Test Note {i}\n\nThis is test content number {i}.",
"tags": "test,bulk",
},
@@ -419,7 +419,7 @@ async def test_chatgpt_integration_workflow(mcp_server, app, test_project):
{
"project": test_project.name,
"title": doc["title"],
"folder": "architecture",
"directory": "architecture",
"content": doc["content"],
"tags": doc["tags"],
},
@@ -34,7 +34,7 @@ async def test_default_project_mode_enabled_write_note(mcp_server, app, test_pro
"write_note",
{
"title": "Default Mode Test",
"folder": "test",
"directory": "test",
"content": "# Default Mode Test\n\nThis should use the default project automatically.",
"tags": "default,mode,test",
},
@@ -86,7 +86,7 @@ async def test_default_project_mode_explicit_override(
"write_note",
{
"title": "Override Test",
"folder": "test",
"directory": "test",
"content": "# Override Test\n\nThis should go to the explicitly specified project.",
"project": other_project.name, # Explicit override
},
@@ -120,7 +120,7 @@ async def test_default_project_mode_disabled_requires_project(mcp_server, app, t
"write_note",
{
"title": "Should Fail",
"folder": "test",
"directory": "test",
"content": "# Should Fail\n\nThis should fail because no project specified.",
},
)
@@ -173,7 +173,7 @@ async def test_cli_constraint_overrides_default_project_mode(
"write_note",
{
"title": "CLI Constraint Test",
"folder": "test",
"directory": "test",
"content": "# CLI Constraint Test\n\nThis should use CLI constrained project.",
},
)
@@ -210,7 +210,7 @@ async def test_default_project_mode_read_note(mcp_server, app, test_project):
"write_note",
{
"title": "Read Test Note",
"folder": "test",
"directory": "test",
"content": "# Read Test Note\n\nThis note will be read back.",
},
)
@@ -249,7 +249,7 @@ async def test_default_project_mode_edit_note(mcp_server, app, test_project):
"write_note",
{
"title": "Edit Test Note",
"folder": "test",
"directory": "test",
"content": "# Edit Test Note\n\nOriginal content.",
},
)
@@ -325,7 +325,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "CLI Priority Test",
"folder": "test",
"directory": "test",
"content": "# CLI Priority Test",
"project": explicit_project.name, # Should be ignored
},
@@ -343,7 +343,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "Explicit Priority Test",
"folder": "test",
"directory": "test",
"content": "# Explicit Priority Test",
"project": explicit_project.name,
},
@@ -358,7 +358,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "Default Priority Test",
"folder": "test",
"directory": "test",
"content": "# Default Priority Test",
# No project specified
},
@@ -0,0 +1,268 @@
"""
Integration tests for delete_note with is_directory=True.
Tests the complete directory delete workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_delete_directory_basic(mcp_server, app, test_project):
"""Test basic directory delete operation."""
async with Client(mcp_server) as client:
# Create multiple notes in a source directory
for i in range(3):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Doc {i + 1}",
"directory": "delete-dir",
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
"tags": "test,delete-dir",
},
)
# Verify notes exist
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"delete-dir/doc-{i + 1}",
},
)
assert f"Content for document {i + 1}" in read_result.content[0].text
# Delete the entire directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "delete-dir",
"is_directory": True,
},
)
# Should return successful delete message with summary
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 3" in delete_text
assert "delete-dir" in delete_text
# Verify all notes are deleted
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"delete-dir/doc-{i + 1}",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_nested(mcp_server, app, test_project):
"""Test deleting a directory with nested subdirectories."""
async with Client(mcp_server) as client:
# Create notes in nested structure
directories = [
"to-delete/2024",
"to-delete/2024/q1",
"to-delete/2024/q2",
]
for dir_path in directories:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"# Note\n\nContent in {dir_path}.",
"tags": "test,nested",
},
)
# Delete the parent directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "to-delete/2024",
"is_directory": True,
},
)
# Should delete all nested files
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 3" in delete_text
# Verify all nested notes are deleted
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "to-delete/2024/q1/note-in-q1",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_empty(mcp_server, app, test_project):
"""Test deleting an empty/non-existent directory returns appropriate message."""
async with Client(mcp_server) as client:
# Try to delete a non-existent/empty directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "nonexistent-dir",
"is_directory": True,
},
)
# Should return message about no files found
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
# Either shows "Directory Deleted Successfully" with 0 files or similar
assert "Total files: 0" in delete_text or "0" in delete_text
@pytest.mark.asyncio
async def test_delete_directory_single_file(mcp_server, app, test_project):
"""Test deleting a directory with only one file."""
async with Client(mcp_server) as client:
# Create single note in directory
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Single Note",
"directory": "single-delete-dir",
"content": "# Single Note\n\nOnly note in this directory.",
"tags": "test,single",
},
)
# Delete directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "single-delete-dir",
"is_directory": True,
},
)
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 1" in delete_text
# Verify note is deleted
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "single-delete-dir/single-note",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_search_no_longer_finds(mcp_server, app, test_project):
"""Test that deleted directory contents are no longer searchable."""
async with Client(mcp_server) as client:
# Create searchable note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Searchable Delete Doc",
"directory": "searchable-delete-dir",
"content": "# Searchable Delete Doc\n\nUnique fusionreactor quantum content.",
"tags": "search,test",
},
)
# Verify searchable before delete
search_before = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "fusionreactor quantum",
},
)
assert "Searchable Delete Doc" in search_before.content[0].text
# Delete directory
await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "searchable-delete-dir",
"is_directory": True,
},
)
# Verify no longer searchable after delete
search_after = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "fusionreactor quantum",
},
)
search_text = search_after.content[0].text
# Should not find the deleted note
assert "Searchable Delete Doc" not in search_text or "No results" in search_text
@pytest.mark.asyncio
async def test_delete_directory_many_files(mcp_server, app, test_project):
"""Test deleting a directory with more than 10 files shows truncated list."""
async with Client(mcp_server) as client:
# Create 12 notes in the directory
for i in range(12):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"DeleteManyDoc {i + 1}",
"directory": "delete-many-dir",
"content": f"# Delete Many Doc {i + 1}\n\nContent {i + 1}.",
"tags": "test,many",
},
)
# Delete the entire directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "delete-many-dir",
"is_directory": True,
},
)
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 12" in delete_text
# Should show truncation message for >10 files
assert "... and 2 more" in delete_text
+8 -8
View File
@@ -19,7 +19,7 @@ async def test_delete_note_by_title(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Note to Delete",
"folder": "test",
"directory": "test",
"content": "# Note to Delete\n\nThis note will be deleted.",
"tags": "test,delete",
},
@@ -77,7 +77,7 @@ async def test_delete_note_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Delete Test",
"folder": "tests",
"directory": "tests",
"content": "# Permalink Delete Test\n\nTesting deletion by permalink.",
"tags": "test,permalink",
},
@@ -140,7 +140,7 @@ The system handles multiple projects and users."""
{
"project": test_project.name,
"title": "Project Management System",
"folder": "projects",
"directory": "projects",
"content": complex_content,
"tags": "project,management,system",
},
@@ -208,7 +208,7 @@ async def test_delete_note_special_characters_in_title(mcp_server, app, test_pro
{
"project": test_project.name,
"title": title,
"folder": "special",
"directory": "special",
"content": f"# {title}\n\nContent for {title}",
"tags": "special,characters",
},
@@ -275,7 +275,7 @@ async def test_delete_note_by_file_path(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "File Path Delete",
"folder": "docs",
"directory": "docs",
"content": "# File Path Delete\n\nTesting deletion by file path.",
"tags": "test,filepath",
},
@@ -320,7 +320,7 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "CamelCase Note Title",
"folder": "test",
"directory": "test",
"content": "# CamelCase Note Title\n\nTesting case sensitivity.",
"tags": "test,case",
},
@@ -359,7 +359,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project)
{
"project": test_project.name,
"title": title,
"folder": "batch",
"directory": "batch",
"content": f"# {title}\n\nContent for {title}",
"tags": "batch,test",
},
@@ -421,7 +421,7 @@ This note contains various Unicode characters:
{
"project": test_project.name,
"title": "Unicode Test Note",
"folder": "unicode",
"directory": "unicode",
"content": unicode_content,
"tags": "unicode,test,emoji",
},
+11 -11
View File
@@ -19,7 +19,7 @@ async def test_edit_note_append_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Append Test Note",
"folder": "test",
"directory": "test",
"content": "# Append Test Note\n\nOriginal content here.",
"tags": "test,append",
},
@@ -69,7 +69,7 @@ async def test_edit_note_prepend_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Prepend Test Note",
"folder": "test",
"directory": "test",
"content": "# Prepend Test Note\n\nExisting content.",
"tags": "test,prepend",
},
@@ -122,7 +122,7 @@ async def test_edit_note_find_replace_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Find Replace Test",
"folder": "test",
"directory": "test",
"content": """# Find Replace Test
This is version v1.0.0 of the system.
@@ -182,7 +182,7 @@ async def test_edit_note_replace_section_operation(mcp_server, app, test_project
{
"project": test_project.name,
"title": "Section Replace Test",
"folder": "test",
"directory": "test",
"content": """# Section Replace Test
## Overview
@@ -268,7 +268,7 @@ Current endpoints include user management."""
{
"project": test_project.name,
"title": "API Documentation",
"folder": "docs",
"directory": "docs",
"content": complex_content,
"tags": "api,docs",
},
@@ -356,7 +356,7 @@ async def test_edit_note_error_handling_text_not_found(mcp_server, app, test_pro
{
"project": test_project.name,
"title": "Error Test Note",
"folder": "test",
"directory": "test",
"content": "# Error Test Note\n\nThis note has specific content.",
"tags": "test,error",
},
@@ -394,7 +394,7 @@ async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app,
{
"project": test_project.name,
"title": "Count Test Note",
"folder": "test",
"directory": "test",
"content": """# Count Test Note
The word "test" appears here.
@@ -437,7 +437,7 @@ async def test_edit_note_invalid_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Invalid Op Test",
"folder": "test",
"directory": "test",
"content": "# Invalid Op Test\n\nSome content.",
"tags": "test",
},
@@ -472,7 +472,7 @@ async def test_edit_note_missing_required_parameters(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Param Test Note",
"folder": "test",
"directory": "test",
"content": "# Param Test Note\n\nContent here.",
"tags": "test",
},
@@ -507,7 +507,7 @@ async def test_edit_note_special_characters_in_content(mcp_server, app, test_pro
{
"project": test_project.name,
"title": "Special Chars Test",
"folder": "test",
"directory": "test",
"content": "# Special Chars Test\n\nBasic content here.",
"tags": "test,unicode",
},
@@ -582,7 +582,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Identifier Test Note",
"folder": "docs",
"directory": "docs",
"content": "# Identifier Test Note\n\nOriginal content.",
"tags": "test,identifier",
},
+23 -23
View File
@@ -19,7 +19,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Root Note",
"folder": "", # Root folder
"directory": "", # Root folder
"content": "# Root Note\n\nThis is in the root directory.",
"tags": "test,root",
},
@@ -30,7 +30,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Planning",
"folder": "projects",
"directory": "projects",
"content": "# Project Planning\n\nPlanning document for projects.",
"tags": "planning,project",
},
@@ -41,7 +41,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes\n\nNotes from the meeting.",
"tags": "meeting,notes",
},
@@ -83,7 +83,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Task List",
"folder": "work",
"directory": "work",
"content": "# Task List\n\nWork tasks for today.",
"tags": "work,tasks",
},
@@ -94,7 +94,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Alpha",
"folder": "work/projects",
"directory": "work/projects",
"content": "# Project Alpha\n\nAlpha project documentation.",
"tags": "project,alpha",
},
@@ -105,7 +105,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Daily Standup",
"folder": "work/meetings",
"directory": "work/meetings",
"content": "# Daily Standup\n\nStandup meeting notes.",
"tags": "meeting,standup",
},
@@ -143,7 +143,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Deep Note",
"folder": "research/ml/algorithms/neural-networks",
"directory": "research/ml/algorithms/neural-networks",
"content": "# Deep Note\n\nDeep learning research.",
"tags": "research,ml,deep",
},
@@ -154,7 +154,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "ML Overview",
"folder": "research/ml",
"directory": "research/ml",
"content": "# ML Overview\n\nMachine learning overview.",
"tags": "research,ml,overview",
},
@@ -165,7 +165,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Research Index",
"folder": "research",
"directory": "research",
"content": "# Research Index\n\nIndex of research topics.",
"tags": "research,index",
},
@@ -203,7 +203,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting 2025-01-15",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting 2025-01-15\n\nMonday meeting notes.",
"tags": "meeting,january",
},
@@ -214,7 +214,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting 2025-01-22",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting 2025-01-22\n\nMonday meeting notes.",
"tags": "meeting,january",
},
@@ -225,7 +225,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Status",
"folder": "meetings",
"directory": "meetings",
"content": "# Project Status\n\nProject status update.",
"tags": "meeting,project",
},
@@ -285,7 +285,7 @@ async def test_list_directory_glob_no_matches(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Document One",
"folder": "docs",
"directory": "docs",
"content": "# Document One\n\nFirst document.",
"tags": "doc",
},
@@ -320,7 +320,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Simple Note",
"folder": "mixed",
"directory": "mixed",
"content": "# Simple Note\n\nA simple note.",
"tags": "simple",
},
@@ -331,7 +331,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Complex Document with Long Title",
"folder": "mixed",
"directory": "mixed",
"content": "# Complex Document with Long Title\n\nA more complex document.",
"tags": "complex,long",
},
@@ -369,7 +369,7 @@ async def test_list_directory_default_parameters(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Default Test",
"folder": "default-test",
"directory": "default-test",
"content": "# Default Test\n\nTesting default parameters.",
"tags": "default",
},
@@ -401,7 +401,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Level 5 Note",
"folder": "level1/level2/level3/level4/level5",
"directory": "level1/level2/level3/level4/level5",
"content": "# Level 5 Note\n\nVery deep note.",
"tags": "deep,level5",
},
@@ -412,7 +412,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Level 3 Note",
"folder": "level1/level2/level3",
"directory": "level1/level2/level3",
"content": "# Level 3 Note\n\nMid-level note.",
"tags": "medium,level3",
},
@@ -449,7 +449,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Project Alpha Plan",
"folder": "patterns",
"directory": "patterns",
"content": "# Project Alpha Plan\n\nAlpha planning.",
"tags": "project,alpha",
},
@@ -460,7 +460,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Project Beta Plan",
"folder": "patterns",
"directory": "patterns",
"content": "# Project Beta Plan\n\nBeta planning.",
"tags": "project,beta",
},
@@ -471,7 +471,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Meeting Minutes",
"folder": "patterns",
"directory": "patterns",
"content": "# Meeting Minutes\n\nMeeting notes.",
"tags": "meeting",
},
@@ -508,7 +508,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Artifact One",
"folder": "artifacts",
"directory": "artifacts",
"content": "# Artifact One\n\nFirst artifact document.",
"tags": "artifact,test",
},
@@ -519,7 +519,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Artifact Two",
"folder": "artifacts",
"directory": "artifacts",
"content": "# Artifact Two\n\nSecond artifact document.",
"tags": "artifact,test",
},
@@ -0,0 +1,343 @@
"""
Integration tests for move_note with is_directory=True.
Tests the complete directory move workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_move_directory_basic(mcp_server, app, test_project):
"""Test basic directory move operation."""
async with Client(mcp_server) as client:
# Create multiple notes in a source directory
for i in range(3):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Doc {i + 1}",
"directory": "source-dir",
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
"tags": "test,move-dir",
},
)
# Move the entire directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "source-dir",
"destination_path": "dest-dir",
"is_directory": True,
},
)
# Should return successful move message with summary
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 3" in move_text
assert "source-dir" in move_text
assert "dest-dir" in move_text
# Verify all notes can be read from new locations
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"dest-dir/doc-{i + 1}",
},
)
content = read_result.content[0].text
assert f"Content for document {i + 1}" in content
# Verify original locations no longer exist
for i in range(3):
read_original = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"source-dir/doc-{i + 1}",
},
)
assert "Note Not Found" in read_original.content[0].text
@pytest.mark.asyncio
async def test_move_directory_nested(mcp_server, app, test_project):
"""Test moving a directory with nested subdirectories."""
async with Client(mcp_server) as client:
# Create notes in nested structure
directories = [
"projects/2024",
"projects/2024/q1",
"projects/2024/q2",
]
for dir_path in directories:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"# Note\n\nContent in {dir_path}.",
"tags": "test,nested",
},
)
# Move the parent directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "projects/2024",
"destination_path": "archive/2024",
"is_directory": True,
},
)
# Should move all nested files
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 3" in move_text
# Verify nested structure is preserved
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "archive/2024/q1/note-in-q1",
},
)
assert "Content in projects/2024/q1" in read_result.content[0].text
read_result2 = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "archive/2024/q2/note-in-q2",
},
)
assert "Content in projects/2024/q2" in read_result2.content[0].text
@pytest.mark.asyncio
async def test_move_directory_empty(mcp_server, app, test_project):
"""Test moving an empty directory returns appropriate message."""
async with Client(mcp_server) as client:
# Try to move a non-existent/empty directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "nonexistent-dir",
"destination_path": "dest-dir",
"is_directory": True,
},
)
# Should return message about no files found
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "No files found" in move_text or "0" in move_text
@pytest.mark.asyncio
async def test_move_directory_preserves_content(mcp_server, app, test_project):
"""Test that directory move preserves all note content including observations and relations."""
async with Client(mcp_server) as client:
# Create note with complex content
complex_content = """# Complex Note
## Observations
- [feature] Important feature observation
- [tech] Technical detail here
## Relations
- relates_to [[Other Note]]
- implements [[Specification]]
## Content
Detailed content that must be preserved."""
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Complex Note",
"directory": "source-complex",
"content": complex_content,
"tags": "test,complex",
},
)
# Move the directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "source-complex",
"destination_path": "dest-complex",
"is_directory": True,
},
)
assert "Directory Moved Successfully" in move_result.content[0].text
# Verify content preservation
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "dest-complex/complex-note",
},
)
content = read_result.content[0].text
assert "Important feature observation" in content
assert "Technical detail here" in content
assert "relates_to [[Other Note]]" in content
assert "Detailed content that must be preserved" in content
@pytest.mark.asyncio
async def test_move_directory_search_still_works(mcp_server, app, test_project):
"""Test that moved directory contents remain searchable."""
async with Client(mcp_server) as client:
# Create searchable note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Searchable Doc",
"directory": "searchable-dir",
"content": "# Searchable Doc\n\nUnique quantum entanglement research content.",
"tags": "search,test",
},
)
# Verify searchable before move
search_before = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "quantum entanglement",
},
)
assert "Searchable Doc" in search_before.content[0].text
# Move directory
await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "searchable-dir",
"destination_path": "moved-searchable",
"is_directory": True,
},
)
# Verify still searchable after move
search_after = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "quantum entanglement",
},
)
search_text = search_after.content[0].text
assert "quantum entanglement" in search_text
assert "moved-searchable" in search_text or "searchable-doc" in search_text
@pytest.mark.asyncio
async def test_move_directory_single_file(mcp_server, app, test_project):
"""Test moving a directory with only one file."""
async with Client(mcp_server) as client:
# Create single note in directory
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Single Note",
"directory": "single-dir",
"content": "# Single Note\n\nOnly note in this directory.",
"tags": "test,single",
},
)
# Move directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "single-dir",
"destination_path": "moved-single",
"is_directory": True,
},
)
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 1" in move_text
# Verify note at new location
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "moved-single/single-note",
},
)
assert "Only note in this directory" in read_result.content[0].text
@pytest.mark.asyncio
async def test_move_directory_many_files(mcp_server, app, test_project):
"""Test moving a directory with more than 10 files shows truncated list."""
async with Client(mcp_server) as client:
# Create 12 notes in the directory
for i in range(12):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"ManyDoc {i + 1}",
"directory": "many-files-dir",
"content": f"# Many Doc {i + 1}\n\nContent {i + 1}.",
"tags": "test,many",
},
)
# Move the directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "many-files-dir",
"destination_path": "moved-many",
"is_directory": True,
},
)
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 12" in move_text
# Should show truncation message for >10 files
assert "... and 2 more" in move_text
+14 -14
View File
@@ -19,7 +19,7 @@ async def test_move_note_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Move Test Note",
"folder": "source",
"directory": "source",
"content": "# Move Test Note\n\nThis note will be moved to a new location.",
"tags": "test,move",
},
@@ -79,7 +79,7 @@ async def test_move_note_using_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Move Test",
"folder": "test",
"directory": "test",
"content": "# Permalink Move Test\n\nMoving by permalink.",
"tags": "test,permalink",
},
@@ -142,7 +142,7 @@ This note demonstrates moving complex content."""
{
"project": test_project.name,
"title": "Complex Note",
"folder": "complex",
"directory": "complex",
"content": complex_content,
"tags": "test,complex,move",
},
@@ -193,7 +193,7 @@ async def test_move_note_to_nested_directory(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Nested Move Test",
"folder": "root",
"directory": "root",
"content": "# Nested Move Test\n\nThis will be moved deep.",
"tags": "test,nested",
},
@@ -239,7 +239,7 @@ async def test_move_note_with_special_characters(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Special (Chars) & Symbols",
"folder": "special",
"directory": "special",
"content": "# Special (Chars) & Symbols\n\nTesting special characters in move.",
"tags": "test,special",
},
@@ -306,7 +306,7 @@ async def test_move_note_error_handling_invalid_destination(mcp_server, app, tes
{
"project": test_project.name,
"title": "Invalid Dest Test",
"folder": "test",
"directory": "test",
"content": "# Invalid Dest Test\n\nThis move should fail.",
"tags": "test,error",
},
@@ -340,7 +340,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
{
"project": test_project.name,
"title": "Source Note",
"folder": "source",
"directory": "source",
"content": "# Source Note\n\nThis is the source.",
"tags": "test,source",
},
@@ -352,7 +352,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
{
"project": test_project.name,
"title": "Existing Note",
"folder": "destination",
"directory": "destination",
"content": "# Existing Note\n\nThis already exists.",
"tags": "test,existing",
},
@@ -386,7 +386,7 @@ async def test_move_note_preserves_search_functionality(mcp_server, app, test_pr
{
"project": test_project.name,
"title": "Searchable Note",
"folder": "original",
"directory": "original",
"content": """# Searchable Note
This note contains unique search terms:
@@ -467,7 +467,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Title ID Note",
"folder": "test",
"directory": "test",
"content": "# Title ID Note\n\nMove by title.",
"tags": "test,identifier",
},
@@ -478,7 +478,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Permalink ID Note",
"folder": "test",
"directory": "test",
"content": "# Permalink ID Note\n\nMove by permalink.",
"tags": "test,identifier",
},
@@ -489,7 +489,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Folder Title Note",
"folder": "test",
"directory": "test",
"content": "# Folder Title Note\n\nMove by folder/title.",
"tags": "test,identifier",
},
@@ -569,7 +569,7 @@ async def test_move_note_cross_project_detection(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Cross Project Test Note",
"folder": "source",
"directory": "source",
"content": "# Cross Project Test Note\n\nThis note is in the default project.",
"tags": "test,cross-project",
},
@@ -605,7 +605,7 @@ async def test_move_note_normal_moves_still_work(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Normal Move Note",
"folder": "source",
"directory": "source",
"content": "# Normal Move Note\n\nThis should move normally.",
"tags": "test,normal-move",
},
@@ -276,7 +276,7 @@ async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_pat
{
"project": project_name,
"title": "Lifecycle Test Note",
"folder": "test",
"directory": "test",
"content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing",
"tags": "lifecycle,test",
},
@@ -390,7 +390,7 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project,
{
"project": test_input, # Use different case
"title": f"Case Test {test_input}",
"folder": "case-test",
"directory": "case-test",
"content": f"# Case Test\n\nTesting with {test_input}",
},
)
@@ -427,7 +427,7 @@ async def test_case_insensitive_project_operations(mcp_server, app, test_project
{
"project": project_name,
"title": "Case Test Note",
"folder": "case-test",
"directory": "case-test",
"content": "# Case Test Note\n\nTesting case-insensitive operations.\n\n- [test] Case insensitive switch\n- relates_to [[Another Note]]",
"tags": "case,test",
},
@@ -478,7 +478,7 @@ async def test_case_insensitive_error_handling(mcp_server, app, test_project):
{
"project": test_case,
"title": "Test Note",
"folder": "test",
"directory": "test",
"content": "# Test\n\nTest content.",
},
)
@@ -524,7 +524,7 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project,
{
"project": project_name, # Use exact project name
"title": f"Test Note {project_name}",
"folder": "test",
"directory": "test",
"content": f"# Test\n\nTesting {project_name}",
},
)
@@ -41,7 +41,7 @@ async def test_project_state_sync_after_default_change(
{
"project": "minerva",
"title": "Test Consistency Note",
"folder": "test",
"directory": "test",
"content": "# Test Note\n\nThis note tests project state consistency.\n\n- [test] Project state sync working",
"tags": "test,consistency",
},
@@ -29,7 +29,7 @@ async def test_read_content_markdown_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Content Test",
"folder": "test",
"directory": "test",
"content": "# Content Test\n\nThis is test content with **markdown**.",
"tags": "test,content",
},
@@ -72,7 +72,7 @@ async def test_read_content_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Test",
"folder": "docs",
"directory": "docs",
"content": "# Permalink Test\n\nTesting permalink-based content reading.",
},
)
@@ -105,7 +105,7 @@ async def test_read_content_memory_url(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Memory URL Test",
"folder": "test",
"directory": "test",
"content": "# Memory URL Test\n\nTesting memory:// URL handling.",
"tags": "memory,url",
},
@@ -143,7 +143,7 @@ async def test_read_content_unicode_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Unicode Content Test",
"folder": "test",
"directory": "test",
"content": unicode_content,
"tags": "unicode,emoji",
},
@@ -204,7 +204,7 @@ Regular markdown content continues here."""
{
"project": test_project.name,
"title": "Complex Note",
"folder": "docs",
"directory": "docs",
"content": complex_content,
"tags": "complex,frontmatter",
},
@@ -263,7 +263,7 @@ async def test_read_content_empty_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Empty Test",
"folder": "test",
"directory": "test",
"content": "", # Empty content
},
)
@@ -316,7 +316,7 @@ eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident.
{
"project": test_project.name,
"title": "Large Content Note",
"folder": "test",
"directory": "test",
"content": large_content,
"tags": "large,content,test",
},
@@ -362,7 +362,7 @@ async def test_read_content_special_characters_in_filename(mcp_server, app, test
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": f"# {title}\n\nContent for {title}",
},
)
+2 -2
View File
@@ -19,7 +19,7 @@ async def test_read_note_after_write(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Test Note",
"folder": "test",
"directory": "test",
"content": "# Test Note\n\nThis is test content.",
"tags": "test,integration",
},
@@ -64,7 +64,7 @@ async def test_read_note_underscored_folder_by_permalink(mcp_server, app, test_p
{
"project": test_project.name,
"title": "Example Note",
"folder": "_archive/articles",
"directory": "_archive/articles",
"content": "# Example Note\n\nThis is a test note in an underscored folder.",
"tags": "test,archive",
},
+20 -20
View File
@@ -20,7 +20,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Programming Guide",
"folder": "docs",
"directory": "docs",
"content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.",
"tags": "python,programming",
},
@@ -31,7 +31,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Flask Web Development",
"folder": "docs",
"directory": "docs",
"content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.",
"tags": "python,flask,web",
},
@@ -42,7 +42,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "JavaScript Basics",
"folder": "docs",
"directory": "docs",
"content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.",
"tags": "javascript,programming",
},
@@ -78,7 +78,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Flask Tutorial",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.",
"tags": "python,flask,tutorial",
},
@@ -89,7 +89,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Django Guide",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.",
"tags": "python,django,web",
},
@@ -100,7 +100,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "React JavaScript",
"folder": "tutorials",
"directory": "tutorials",
"content": "# React JavaScript\n\nBuilding frontend applications with React.",
"tags": "javascript,react,frontend",
},
@@ -159,7 +159,7 @@ async def test_search_title_only(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Database Design",
"folder": "docs",
"directory": "docs",
"content": "# Database Design\n\nThis covers SQL and database concepts.",
"tags": "database,sql",
},
@@ -170,7 +170,7 @@ async def test_search_title_only(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Web Development",
"folder": "docs",
"directory": "docs",
"content": "# Web Development\n\nDatabase integration in web applications.",
"tags": "web,development",
},
@@ -202,7 +202,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "API Documentation",
"folder": "api",
"directory": "api",
"content": "# API Documentation\n\nComplete API reference guide.",
"tags": "api,docs",
},
@@ -213,7 +213,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "API Testing",
"folder": "testing",
"directory": "testing",
"content": "# API Testing\n\nHow to test REST APIs.",
"tags": "api,testing",
},
@@ -245,7 +245,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes January",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes January\n\nJanuary team meeting notes.",
"tags": "meetings,january",
},
@@ -256,7 +256,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes February",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes February\n\nFebruary team meeting notes.",
"tags": "meetings,february",
},
@@ -267,7 +267,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Notes",
"folder": "projects",
"directory": "projects",
"content": "# Project Notes\n\nGeneral project documentation.",
"tags": "projects,notes",
},
@@ -314,7 +314,7 @@ Regular content about development practices."""
{
"project": test_project.name,
"title": "Development Process",
"folder": "processes",
"directory": "processes",
"content": content_with_observations,
"tags": "development,process",
},
@@ -347,7 +347,7 @@ async def test_search_pagination(mcp_server, app, test_project):
{
"project": test_project.name,
"title": f"Test Note {i + 1:02d}",
"folder": "test",
"directory": "test",
"content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.",
"tags": "test,pagination",
},
@@ -395,7 +395,7 @@ async def test_search_no_results(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Sample Note",
"folder": "test",
"directory": "test",
"content": "# Sample Note\n\nThis is a sample note for testing.",
"tags": "sample,test",
},
@@ -425,7 +425,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Web Development",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.",
"tags": "python,web,development",
},
@@ -436,7 +436,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Data Science",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Data Science\n\nData analysis and machine learning with Python.",
"tags": "python,data,science",
},
@@ -447,7 +447,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "JavaScript Web Development",
"folder": "tutorials",
"directory": "tutorials",
"content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.",
"tags": "javascript,web,development",
},
@@ -479,7 +479,7 @@ async def test_search_case_insensitive(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Machine Learning Guide",
"folder": "guides",
"directory": "guides",
"content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.",
"tags": "ML,AI",
},
@@ -26,7 +26,7 @@ async def test_project_constraint_override_content_tools(mcp_server, app, test_p
{
"project": "some-other-project", # Should be ignored
"title": "Constraint Test Note",
"folder": "test",
"directory": "test",
"content": "# Constraint Test\n\nThis should go to the constrained project.",
"tags": "constraint,test",
},
@@ -62,7 +62,7 @@ async def test_project_constraint_read_note_override(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Read Test Note",
"folder": "test",
"directory": "test",
"content": "# Read Test\n\nContent for reading test.",
},
)
@@ -103,7 +103,7 @@ async def test_project_constraint_search_notes_override(mcp_server, app, test_pr
{
"project": test_project.name,
"title": "Searchable Note",
"folder": "test",
"directory": "test",
"content": "# Searchable\n\nThis content has unique searchable terms.",
},
)
@@ -221,7 +221,7 @@ async def test_normal_mode_without_constraint(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Normal Mode Note",
"folder": "test",
"directory": "test",
"content": "# Normal Mode\n\nThis should work normally.",
},
)
@@ -254,7 +254,7 @@ async def test_constraint_with_multiple_content_tools(mcp_server, app, test_proj
{
"project": "wrong-project",
"title": "Multi Tool Test",
"folder": "test",
"directory": "test",
"content": "# Multi Tool Test\n\n- [note] Testing multiple tools",
},
)
@@ -324,7 +324,7 @@ async def test_constraint_with_invalid_project_override(mcp_server, app, test_pr
{
"project": invalid_project,
"title": f"Test Invalid {i} {invalid_project[:5]}",
"folder": "test",
"directory": "test",
"content": f"Testing with invalid project: {invalid_project}",
},
)
+13 -13
View File
@@ -25,7 +25,7 @@ async def test_write_note_basic_creation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Simple Note",
"folder": "basic",
"directory": "basic",
"content": "# Simple Note\n\nThis is a simple note for testing.",
"tags": "simple,test",
},
@@ -54,7 +54,7 @@ async def test_write_note_no_tags(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "No Tags Note",
"folder": "test",
"directory": "test",
"content": "Just some plain text without tags.",
},
)
@@ -80,7 +80,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Update Test",
"folder": "test",
"directory": "test",
"content": "# Update Test\n\nOriginal content.",
"tags": "original",
},
@@ -94,7 +94,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Update Test",
"folder": "test",
"directory": "test",
"content": "# Update Test\n\nUpdated content with changes.",
"tags": "updated,modified",
},
@@ -123,7 +123,7 @@ async def test_write_note_tag_array(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Array Tags Test",
"folder": "test",
"directory": "test",
"content": "Testing tag array handling",
"tags": ["python", "testing", "integration", "mcp"],
},
@@ -164,7 +164,7 @@ async def test_write_note_custom_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Custom Permalink Note",
"folder": "notes",
"directory": "notes",
"content": content_with_custom_permalink,
},
)
@@ -192,7 +192,7 @@ async def test_write_note_unicode_content(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Unicode Test 🌟",
"folder": "test",
"directory": "test",
"content": unicode_content,
"tags": "unicode,emoji,测试",
},
@@ -243,7 +243,7 @@ async def test_write_note_complex_content_with_observations_relations(
{
"project": test_project.name,
"title": "Complex Knowledge Note",
"folder": "knowledge",
"directory": "knowledge",
"content": complex_content,
"tags": "complex,knowledge,relations",
},
@@ -296,7 +296,7 @@ async def test_write_note_preserve_frontmatter(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Frontmatter Note",
"folder": "test",
"directory": "test",
"content": content_with_frontmatter,
"tags": "frontmatter,preservation",
},
@@ -326,7 +326,7 @@ async def test_write_note_kebab_filenames_basic(mcp_server, app, test_project, a
{
"project": test_project.name,
"title": "My Note: With/Invalid|Chars?",
"folder": "my-folder",
"directory": "my-folder",
"content": "Testing kebab-case and invalid characters.",
"tags": "kebab,invalid,filename",
},
@@ -355,7 +355,7 @@ async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, app, test_p
{
"project": test_project.name,
"title": 'Crazy<>:"|?*Note/Name',
"folder": "my-folder",
"directory": "my-folder",
"content": "Should be fully kebab-case and safe.",
"tags": "crazy,filename,test",
},
@@ -404,7 +404,7 @@ async def test_write_note_file_path_os_path_join(mcp_server, app, test_project,
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": "Testing os.path.join logic.",
"tags": "integration,ospath",
},
@@ -462,7 +462,7 @@ async def test_write_note_project_path_validation(mcp_server, app, test_project)
{
"project": test_project.name,
"title": "Validation Test",
"folder": "documents",
"directory": "documents",
"content": "Testing path validation",
"tags": "test",
},
@@ -61,7 +61,7 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_co
# Create entity via API
entity_data = EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Test content",
)
+11 -11
View File
@@ -153,7 +153,7 @@ async def test_import_chatgpt(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -186,7 +186,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, projec
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request - this should return an error
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -207,7 +207,7 @@ async def test_import_claude_conversations(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request
response = await client.post(
@@ -242,7 +242,7 @@ async def test_import_claude_conversations_invalid_file(client: AsyncClient, tmp
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request - this should return an error
response = await client.post(
@@ -265,7 +265,7 @@ async def test_import_claude_projects(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("projects.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request
response = await client.post(
@@ -305,7 +305,7 @@ async def test_import_claude_projects_invalid_file(client: AsyncClient, tmp_path
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request - this should return an error
response = await client.post(
@@ -331,7 +331,7 @@ async def test_import_memory_json(
# Create a multipart form with the file
with open(json_file, "rb") as f:
files = {"file": ("memory.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request
response = await client.post(f"{project_url}/import/memory-json", files=files, data=data)
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, pr
async def test_import_missing_file(client: AsyncClient, tmp_path, project_url):
"""Test importing with missing file."""
# Send a request without a file
response = await client.post(f"{project_url}/import/chatgpt", data={"folder": "test_folder"})
response = await client.post(f"{project_url}/import/chatgpt", data={"directory": "test_folder"})
# Check that the request was rejected
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
@@ -426,7 +426,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, project_url):
# Create multipart form with empty file
with open(file_path, "rb") as f:
files = {"file": ("empty.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -446,8 +446,8 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, project_url)
# Test all import endpoints
endpoints = [
(f"{project_url}/import/chatgpt", {"folder": "test"}),
(f"{project_url}/import/claude/conversations", {"folder": "test"}),
(f"{project_url}/import/chatgpt", {"directory": "test"}),
(f"{project_url}/import/claude/conversations", {"directory": "test"}),
(f"{project_url}/import/claude/projects", {"base_folder": "test"}),
(f"{project_url}/import/memory-json", {"destination_folder": "test"}),
]
+161 -44
View File
@@ -9,6 +9,7 @@ from basic_memory.schemas import (
Entity,
EntityResponse,
)
from basic_memory.schemas.response import DirectoryMoveResult
from basic_memory.schemas.search import SearchItemType, SearchResponse
from basic_memory.utils import normalize_newlines
@@ -19,7 +20,7 @@ async def test_create_entity(client: AsyncClient, file_service, project_url):
data = {
"title": "TestEntity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "TestContent",
"project": "Test Project Context",
@@ -53,7 +54,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
data = {
"title": "TestEntity",
"folder": "test",
"directory": "test",
"content": """
# TestContent
@@ -98,7 +99,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
# Create first entity with unresolved relation
entity1_data = {
"title": "EntityOne",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "This entity references [[EntityTwo]]",
}
@@ -116,7 +117,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
# Create the referenced entity
entity2_data = {
"title": "EntityTwo",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "This is the referenced entity",
}
@@ -141,7 +142,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
async def test_get_entity_by_permalink(client: AsyncClient, project_url):
"""Should retrieve an entity by path ID."""
# First create an entity
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=data)
assert response.status_code == 200
data = response.json()
@@ -163,7 +164,7 @@ async def test_get_entity_by_permalink(client: AsyncClient, project_url):
async def test_get_entity_by_file_path(client: AsyncClient, project_url):
"""Should retrieve an entity by path ID."""
# First create an entity
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=data)
assert response.status_code == 200
data = response.json()
@@ -187,11 +188,11 @@ async def test_get_entities(client: AsyncClient, project_url):
# Create a few entities with different names
await client.post(
f"{project_url}/knowledge/entities",
json={"title": "AlphaTest", "folder": "", "entity_type": "test"},
json={"title": "AlphaTest", "directory": "", "entity_type": "test"},
)
await client.post(
f"{project_url}/knowledge/entities",
json={"title": "BetaTest", "folder": "", "entity_type": "test"},
json={"title": "BetaTest", "directory": "", "entity_type": "test"},
)
# Open nodes by path IDs
@@ -241,7 +242,7 @@ async def test_delete_entity(client: AsyncClient, project_url):
async def test_delete_single_entity(client: AsyncClient, project_url):
"""Test DELETE /knowledge/entities with path ID."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
await client.post(f"{project_url}/knowledge/entities", json=entity_data)
# Test deletion
@@ -259,7 +260,7 @@ async def test_delete_single_entity(client: AsyncClient, project_url):
async def test_delete_single_entity_by_title(client: AsyncClient, project_url):
"""Test DELETE /knowledge/entities with file path."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
data = response.json()
@@ -328,7 +329,7 @@ async def test_entity_indexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SearchTest",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["Unique searchable observation"],
},
@@ -356,7 +357,7 @@ async def test_entity_delete_indexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "DeleteTest",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["Searchable observation that should be removed"],
},
@@ -394,7 +395,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"content": "Initial summary",
"entity_metadata": {"status": "draft"},
@@ -403,7 +404,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.entity_metadata["status"] = "final"
entity.content = "Updated summary"
@@ -429,12 +430,12 @@ async def test_update_entity_content(client: AsyncClient, project_url):
# Create a note entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={"title": "test-note", "folder": "", "entity_type": "note", "summary": "Test note"},
json={"title": "test-note", "directory": "", "entity_type": "note", "summary": "Test note"},
)
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity = Entity(**note, directory="")
entity.content = "# Updated Note\n\nNew content."
response = await client.put(
@@ -458,7 +459,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
# Create a note
note_data = {
"title": "test-note",
"folder": "",
"directory": "",
"entity_type": "note",
"summary": "Test note",
"content": "# Test Note\n\nInitial content.",
@@ -467,7 +468,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity = Entity(**note, directory="")
entity.entity_type = "test"
response = await client.put(
@@ -491,7 +492,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
# Create entity
data = {
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"entity_metadata": {"status": "draft"},
}
@@ -499,7 +500,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.entity_metadata["status"] = "final"
entity.entity_metadata["reviewed"] = True
@@ -521,7 +522,7 @@ async def test_update_entity_not_found_does_create(client: AsyncClient, project_
data = {
"title": "nonexistent",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
@@ -538,7 +539,7 @@ async def test_update_entity_incorrect_permalink(client: AsyncClient, project_ur
data = {
"title": "Test Entity",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
@@ -555,7 +556,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
# Create entity
data = {
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"content": "Initial searchable content",
}
@@ -563,7 +564,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.content = "Updated with unique sphinx marker"
response = await client.put(
@@ -592,7 +593,7 @@ async def test_edit_entity_append(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -627,7 +628,7 @@ async def test_edit_entity_prepend(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -671,7 +672,7 @@ async def test_edit_entity_find_replace(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "This is old content that needs updating",
},
@@ -704,7 +705,7 @@ async def test_edit_entity_find_replace_with_expected_replacements(
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
@@ -747,7 +748,7 @@ Original section 2 content"""
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": content,
},
@@ -794,7 +795,7 @@ async def test_edit_entity_invalid_operation(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -819,7 +820,7 @@ async def test_edit_entity_find_replace_missing_find_text(client: AsyncClient, p
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -844,7 +845,7 @@ async def test_edit_entity_replace_section_missing_section(client: AsyncClient,
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -869,7 +870,7 @@ async def test_edit_entity_find_replace_not_found(client: AsyncClient, project_u
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "This is some content",
},
@@ -894,7 +895,7 @@ async def test_edit_entity_find_replace_wrong_expected_count(client: AsyncClient
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
@@ -925,7 +926,7 @@ async def test_edit_entity_search_reindex(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Search Test",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original searchable content",
},
@@ -961,7 +962,7 @@ async def test_move_entity_success(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1006,7 +1007,7 @@ async def test_move_entity_with_folder_creation(client: AsyncClient, project_url
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"directory": "",
"entity_type": "note",
"content": "Test content",
},
@@ -1047,7 +1048,7 @@ Some additional content."""
f"{project_url}/knowledge/entities",
json={
"title": "ComplexEntity",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": content,
},
@@ -1098,7 +1099,7 @@ async def test_move_entity_search_reindexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SearchableNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Unique searchable elephant content",
},
@@ -1144,7 +1145,7 @@ async def test_move_entity_invalid_destination_path(client: AsyncClient, project
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"directory": "",
"entity_type": "note",
"content": "Test content",
},
@@ -1177,7 +1178,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SourceNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Source content",
},
@@ -1190,7 +1191,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "DestinationNote",
"folder": "target",
"directory": "target",
"entity_type": "note",
"content": "Destination content",
},
@@ -1235,7 +1236,7 @@ async def test_move_entity_by_file_path(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1266,7 +1267,7 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "UniqueTestTitle",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1287,3 +1288,119 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
moved_entity = response.json()
assert moved_entity["file_path"] == "target/MovedByTitle.md"
assert moved_entity["title"] == "UniqueTestTitle"
# --- Move directory tests ---
@pytest.mark.asyncio
async def test_move_directory_success(client: AsyncClient, project_url):
"""Test POST /move-directory endpoint successfully moves all files in a directory."""
# Create multiple notes in a source directory
for i in range(3):
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": f"DirMoveDoc{i + 1}",
"directory": "move-source",
"entity_type": "note",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Move the entire directory
move_data = {
"source_directory": "move-source",
"destination_directory": "move-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_moves == 3
assert result.failed_moves == 0
assert len(result.moved_files) == 3
# Verify notes are accessible at new location
for i in range(3):
response = await client.get(
f"{project_url}/knowledge/entities/move-dest/dir-move-doc{i + 1}"
)
assert response.status_code == 200
entity = response.json()
assert entity["file_path"].startswith("move-dest/")
@pytest.mark.asyncio
async def test_move_directory_empty_directory(client: AsyncClient, project_url):
"""Test move_directory with no files in source returns zero counts."""
move_data = {
"source_directory": "nonexistent-source-dir",
"destination_directory": "some-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_moves == 0
assert result.failed_moves == 0
assert len(result.moved_files) == 0
@pytest.mark.asyncio
async def test_move_directory_validation_error(client: AsyncClient, project_url):
"""Test move_directory with missing required fields returns validation error."""
# Missing destination_directory
move_data = {
"source_directory": "some-source",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 422
# Missing source_directory
move_data = {
"destination_directory": "some-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_move_directory_nested_structure(client: AsyncClient, project_url):
"""Test move_directory preserves nested directory structure."""
# Create notes in nested structure
directories = [
"nested-move/2024",
"nested-move/2024/q1",
]
for dir_path in directories:
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"entity_type": "note",
"content": f"Content in {dir_path}",
},
)
assert response.status_code == 200
# Move the parent directory
move_data = {
"source_directory": "nested-move/2024",
"destination_directory": "archive/2024",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 2
assert result.successful_moves == 2
# Verify nested note is at new location
response = await client.get(f"{project_url}/knowledge/entities/archive/2024/q1/note-in-q1")
assert response.status_code == 200
+7 -7
View File
@@ -133,7 +133,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor
content = "# Test Content\n\n- [note] an observation."
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content}",
}
@@ -173,7 +173,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -184,7 +184,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
@@ -229,7 +229,7 @@ async def test_get_resource_entities_pagination(
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -241,7 +241,7 @@ async def test_get_resource_entities_pagination(
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
@@ -281,7 +281,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -292,7 +292,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
+1 -1
View File
@@ -132,7 +132,7 @@ async def test_reindex(
await entity_service.create_entity(
EntitySchema(
title="TestEntity1",
folder="test",
directory="test",
entity_type="test",
),
)
+16 -16
View File
@@ -159,7 +159,7 @@ async def test_import_chatgpt(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -192,7 +192,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, v2_pro
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request - this should return an error
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -217,7 +217,7 @@ async def test_import_claude_conversations(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request
response = await client.post(
@@ -254,7 +254,7 @@ async def test_import_claude_conversations_invalid_file(
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request - this should return an error
response = await client.post(
@@ -277,7 +277,7 @@ async def test_import_claude_projects(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("projects.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request
response = await client.post(
@@ -319,7 +319,7 @@ async def test_import_claude_projects_invalid_file(
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request - this should return an error
response = await client.post(
@@ -345,7 +345,7 @@ async def test_import_memory_json(
# Create a multipart form with the file
with open(json_file, "rb") as f:
files = {"file": ("memory.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, v2
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request - this should return an error
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
@@ -430,7 +430,7 @@ async def test_v2_import_endpoints_use_project_id_not_name(
# Try using project name instead of ID - should fail
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test"}
data = {"directory": "test"}
response = await client.post(
f"/v2/projects/{test_project.name}/import/chatgpt",
@@ -459,7 +459,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
for endpoint in endpoints:
with open(file_path, "rb") as f:
files = {"file": ("test.json", f, "application/json")}
data = {"folder": "test"}
data = {"directory": "test"}
response = await client.post(
f"/v2/projects/999999{endpoint}",
@@ -474,7 +474,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
"""Test importing with missing file via v2 endpoint."""
# Send a request without a file
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"folder": "test_folder"})
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"directory": "test_folder"})
# Check that the request was rejected
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
@@ -491,7 +491,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, v2_project_url:
# Create multipart form with empty file
with open(file_path, "rb") as f:
files = {"file": ("empty.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -511,10 +511,10 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, v2_project_u
# Test all import endpoints
endpoints = [
(f"{v2_project_url}/import/chatgpt", {"folder": "test"}),
(f"{v2_project_url}/import/claude/conversations", {"folder": "test"}),
(f"{v2_project_url}/import/claude/projects", {"folder": "test"}),
(f"{v2_project_url}/import/memory-json", {"folder": "test"}),
(f"{v2_project_url}/import/chatgpt", {"directory": "test"}),
(f"{v2_project_url}/import/claude/conversations", {"directory": "test"}),
(f"{v2_project_url}/import/claude/projects", {"directory": "test"}),
(f"{v2_project_url}/import/memory-json", {"directory": "test"}),
]
for endpoint, data in endpoints:
+237 -11
View File
@@ -5,6 +5,7 @@ from httpx import AsyncClient
from basic_memory.models import Project
from basic_memory.schemas import DeleteEntitiesResponse
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
@@ -19,7 +20,7 @@ async def test_resolve_identifier_by_permalink(
# Create an entity first
entity_data = {
"title": "TestResolve",
"folder": "test",
"directory": "test",
"content": "Test content for resolve",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -51,13 +52,67 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that resolve uses strict mode - no fuzzy search fallback.
This ensures wiki links only resolve to exact matches (permalink, title, or path),
not to similar-sounding entities via fuzzy search.
"""
# Create an entity with a specific name
entity_data = {
"title": "link-test",
"folder": "testing",
"content": "A test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" - should NOT fuzzy match to "link-test"
resolve_data = {"identifier": "nonexistent"}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match to "link-test"
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that context-aware resolution also uses strict mode.
Even with source_path for context-aware resolution, nonexistent
links should return 404, not fuzzy match to nearby entities.
"""
# Create entities in a folder structure
entity_data = {
"title": "link-test",
"folder": "testing/nested",
"content": "A nested test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" with source_path context
# Should NOT fuzzy match to "link-test" in the same or nearby folder
resolve_data = {
"identifier": "nonexistent",
"source_path": "testing/nested/other-note.md",
}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
"""Test getting an entity by its external_id (UUID)."""
# Create an entity first
entity_data = {
"title": "TestGetById",
"folder": "test",
"directory": "test",
"content": "Test content for get by ID",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -94,7 +149,7 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
"""Test creating an entity via v2 endpoint."""
data = {
"title": "TestV2Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content_type": "text/markdown",
"content": "TestContent for V2",
@@ -127,7 +182,7 @@ async def test_create_entity_with_observations_and_relations(
"""Test creating an entity with observations and relations via v2."""
data = {
"title": "TestV2Complex",
"folder": "test",
"directory": "test",
"content": """
# TestV2Complex
@@ -164,7 +219,7 @@ async def test_update_entity_by_id(
# Create an entity first
create_data = {
"title": "TestUpdate",
"folder": "test",
"directory": "test",
"content": "Original content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -178,7 +233,7 @@ async def test_update_entity_by_id(
# Update it by external_id
update_data = {
"title": "TestUpdate",
"folder": "test",
"directory": "test",
"content": "Updated content via V2",
}
response = await client.put(
@@ -208,7 +263,7 @@ async def test_edit_entity_by_id_append(
# Create an entity first
create_data = {
"title": "TestEdit",
"folder": "test",
"directory": "test",
"content": "# TestEdit\n\nOriginal content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -251,7 +306,7 @@ async def test_edit_entity_by_id_find_replace(
# Create an entity first
create_data = {
"title": "TestFindReplace",
"folder": "test",
"directory": "test",
"content": "# TestFindReplace\n\nOld text that will be replaced",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -295,7 +350,7 @@ async def test_delete_entity_by_id(
# Create an entity first
create_data = {
"title": "TestDelete",
"folder": "test",
"directory": "test",
"content": "Content to be deleted",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -337,7 +392,7 @@ async def test_move_entity(client: AsyncClient, file_service, v2_project_url, en
# Create an entity first
create_data = {
"title": "TestMove",
"folder": "test",
"directory": "test",
"content": "Content to be moved",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -390,7 +445,7 @@ async def test_entity_response_v2_has_api_version(
# Create an entity
entity_data = {
"title": "TestApiVersion",
"folder": "test",
"directory": "test",
"content": "Test content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -409,3 +464,174 @@ async def test_entity_response_v2_has_api_version(
entity_v2 = EntityResponseV2.model_validate(response.json())
assert entity_v2.api_version == "v2"
assert entity_v2.external_id == entity_external_id
# --- Move directory tests (V2) ---
@pytest.mark.asyncio
async def test_move_directory_v2_success(client: AsyncClient, v2_project_url):
"""Test POST /v2/.../move-directory endpoint successfully moves all files."""
# Create multiple notes in a source directory
for i in range(3):
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"V2DirMoveDoc{i + 1}",
"directory": "v2-move-source",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Move the entire directory
move_data = {
"source_directory": "v2-move-source",
"destination_directory": "v2-move-dest",
}
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_moves == 3
assert result.failed_moves == 0
assert len(result.moved_files) == 3
@pytest.mark.asyncio
async def test_move_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
"""Test move_directory V2 with no files in source returns zero counts."""
move_data = {
"source_directory": "v2-nonexistent-source",
"destination_directory": "v2-some-dest",
}
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_moves == 0
assert result.failed_moves == 0
@pytest.mark.asyncio
async def test_move_directory_v2_validation_error(client: AsyncClient, v2_project_url):
"""Test move_directory V2 with missing required fields returns validation error."""
# Missing destination_directory
response = await client.post(
f"{v2_project_url}/knowledge/move-directory",
json={"source_directory": "some-source"},
)
assert response.status_code == 422
# Missing source_directory
response = await client.post(
f"{v2_project_url}/knowledge/move-directory",
json={"destination_directory": "some-dest"},
)
assert response.status_code == 422
# --- Delete directory tests (V2) ---
@pytest.mark.asyncio
async def test_delete_directory_v2_success(client: AsyncClient, v2_project_url):
"""Test POST /v2/.../delete-directory endpoint successfully deletes all files."""
# Create multiple notes in a directory to delete
for i in range(3):
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"V2DeleteDoc{i + 1}",
"directory": "v2-delete-dir",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Verify notes exist
created_entity = EntityResponseV2.model_validate(response.json())
get_response = await client.get(
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
)
assert get_response.status_code == 200
# Delete the entire directory
delete_data = {
"directory": "v2-delete-dir",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_deletes == 3
assert result.failed_deletes == 0
assert len(result.deleted_files) == 3
# Verify entity is no longer accessible
get_response = await client.get(
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
)
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_delete_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 with no files returns zero counts."""
delete_data = {
"directory": "v2-nonexistent-delete-dir",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_deletes == 0
assert result.failed_deletes == 0
@pytest.mark.asyncio
async def test_delete_directory_v2_validation_error(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 with missing required fields returns validation error."""
# Missing directory field
response = await client.post(
f"{v2_project_url}/knowledge/delete-directory",
json={},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 handles nested directory structure."""
# Create notes in nested structure
directories = [
"v2-nested-delete/2024",
"v2-nested-delete/2024/q1",
]
for dir_path in directories:
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"Content in {dir_path}",
},
)
assert response.status_code == 200
# Delete the parent directory
delete_data = {
"directory": "v2-nested-delete/2024",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 2
assert result.successful_deletes == 2
assert result.failed_deletes == 0
+409
View File
@@ -0,0 +1,409 @@
"""Tests for cloud restore CLI commands.
SPEC-29 Phase 3: Tests for restore command.
"""
from unittest.mock import Mock, patch
import httpx
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
)
class TestRestoreCommand:
"""Tests for 'bm cloud restore' command."""
def test_restore_file_success_with_force(self):
"""Test successful file restoration with --force flag."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": ["notes/project.md"],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
assert "Successfully restored" in result.stdout
assert "notes/project.md" in result.stdout
def test_restore_folder_success(self):
"""Test successful folder restoration."""
runner = CliRunner()
mock_restore_response = Mock(spec=httpx.Response)
mock_restore_response.status_code = 200
mock_restore_response.json.return_value = {
"restored": [
"research/paper1.md",
"research/paper2.md",
"research/notes.md",
],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_restore_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
["cloud", "restore", "research/", "--snapshot", "snap_123", "--force"],
)
assert result.exit_code == 0
assert "Successfully restored 3 file(s)" in result.stdout
def test_restore_many_files_truncated_output(self):
"""Test restore output is truncated for many files."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": [f"notes/file{i}.md" for i in range(20)],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
["cloud", "restore", "notes/", "--snapshot", "snap_123", "--force"],
)
assert result.exit_code == 0
assert "Successfully restored 20 file(s)" in result.stdout
assert "and 15 more" in result.stdout
def test_restore_no_files_found(self):
"""Test restore when no files match the path."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": [],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"nonexistent/",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
assert "No files were restored" in result.stdout
def test_restore_snapshot_not_found(self):
"""Test restore from non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_nonexistent",
"--force",
],
)
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
def test_restore_subscription_required(self):
"""Test restore requires subscription."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise SubscriptionRequiredError(
message="Active subscription required",
subscribe_url="https://basicmemory.com/subscribe",
)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 1
assert "Subscription Required" in result.stdout
assert "https://basicmemory.com/subscribe" in result.stdout
def test_restore_cancelled_by_user(self):
"""Test restore cancelled by user confirmation."""
runner = CliRunner()
# First call is browse, second would be restore (should not happen)
mock_browse_response = Mock(spec=httpx.Response)
mock_browse_response.status_code = 200
mock_browse_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"}
],
"prefix": "notes/project.md",
"snapshot_version": "12345",
}
call_count = 0
async def mock_make_api_request(method, url, *args, **kwargs):
nonlocal call_count
call_count += 1
if "browse" in url:
return mock_browse_response
# Track unexpected calls - the test will verify later
return mock_browse_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
# Simulate user saying "n" to confirmation
result = runner.invoke(
app,
["cloud", "restore", "notes/project.md", "--snapshot", "snap_123"],
input="n\n",
)
assert result.exit_code == 0
assert "cancelled" in result.stdout
# Only one call should happen (browse), not the restore POST
assert call_count == 1
def test_restore_with_leading_slash_normalized(self):
"""Test that leading slashes are stripped from path."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": ["notes/project.md"],
"snapshot_id": "snap_123",
}
captured_json_data = []
async def mock_make_api_request(*args, **kwargs):
if "json_data" in kwargs:
captured_json_data.append(kwargs["json_data"])
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"/notes/project.md", # Leading slash
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
# Verify the path was normalized (no leading slash)
assert any(data.get("path") == "notes/project.md" for data in captured_json_data)
def test_restore_api_error(self):
"""Test handling generic API errors during restore."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Server error", status_code=500)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 1
assert "Failed to restore" in result.stdout
class TestRestoreCommandHelp:
"""Tests for restore command help and usage."""
def test_restore_requires_snapshot_option(self):
"""Test that --snapshot option is required."""
runner = CliRunner()
result = runner.invoke(app, ["cloud", "restore", "notes/project.md"])
# Should fail due to missing required option (exit code 2 for usage error)
assert result.exit_code == 2
# Typer writes the error message to the output
assert "Missing option" in result.output or "--snapshot" in result.output
def test_restore_requires_path_argument(self):
"""Test that path argument is required."""
runner = CliRunner()
result = runner.invoke(app, ["cloud", "restore", "--snapshot", "snap_123"])
# Should fail due to missing required argument
assert result.exit_code != 0
+102
View File
@@ -0,0 +1,102 @@
"""Tests for CLI routing utilities."""
import os
import pytest
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
class TestValidateRoutingFlags:
"""Tests for validate_routing_flags function."""
def test_neither_flag(self):
"""Should not raise when neither flag is set."""
validate_routing_flags(local=False, cloud=False)
def test_local_only(self):
"""Should not raise when only local is set."""
validate_routing_flags(local=True, cloud=False)
def test_cloud_only(self):
"""Should not raise when only cloud is set."""
validate_routing_flags(local=False, cloud=True)
def test_both_flags_raises(self):
"""Should raise ValueError when both flags are set."""
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
validate_routing_flags(local=True, cloud=True)
class TestForceRouting:
"""Tests for force_routing context manager."""
def test_local_sets_env_var(self):
"""Local flag should set BASIC_MEMORY_FORCE_LOCAL."""
# Ensure env var is not set
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Should be cleaned up after context exits
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
def test_cloud_clears_env_var(self):
"""Cloud flag should clear BASIC_MEMORY_FORCE_LOCAL if set."""
# Set env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
with force_routing(cloud=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
# Should restore original value after context exits
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Cleanup
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_neither_flag_no_change(self):
"""Neither flag should not change env vars."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
with force_routing():
# Should not be set
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
# Should still not be set
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
def test_preserves_original_env_var(self):
"""Should restore original env var value after context exits."""
original_value = "original"
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_value
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Should restore original value
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == original_value
# Cleanup
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_both_flags_raises(self):
"""Should raise ValueError when both flags are set."""
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
with force_routing(local=True, cloud=True):
pass
def test_restores_on_exception(self):
"""Should restore env vars even when exception is raised."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
try:
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
raise RuntimeError("Test exception")
except RuntimeError:
pass
# Should be cleaned up even after exception
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
+529
View File
@@ -0,0 +1,529 @@
"""Tests for cloud snapshot CLI commands.
SPEC-29 Phase 3: Tests for snapshot create, list, delete, show, browse commands.
"""
from unittest.mock import Mock, patch
import httpx
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
)
class TestSnapshotCreateCommand:
"""Tests for 'bm cloud snapshot create' command."""
def test_create_snapshot_success(self):
"""Test successful snapshot creation."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "snap_123",
"bucket_name": "tenant-abc",
"snapshot_version": "1703430000000",
"name": "manual-snapshot",
"description": "before major refactor",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "create", "before major refactor"]
)
assert result.exit_code == 0
assert "Snapshot created successfully" in result.stdout
assert "snap_123" in result.stdout
assert "before major refactor" in result.stdout
def test_create_snapshot_subscription_required(self):
"""Test snapshot creation requires subscription."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise SubscriptionRequiredError(
message="Active subscription required",
subscribe_url="https://basicmemory.com/subscribe",
)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
assert result.exit_code == 1
assert "Subscription Required" in result.stdout
assert "https://basicmemory.com/subscribe" in result.stdout
def test_create_snapshot_api_error(self):
"""Test handling API errors during snapshot creation."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Server error", status_code=500)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
assert result.exit_code == 1
assert "Failed to create snapshot" in result.stdout
class TestSnapshotListCommand:
"""Tests for 'bm cloud snapshot list' command."""
def test_list_snapshots_success(self):
"""Test successful snapshot listing."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"snapshots": [
{
"id": "snap_123",
"name": "snapshot-1",
"description": "first snapshot",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
},
{
"id": "snap_456",
"name": "daily-auto",
"description": "daily backup",
"auto": True,
"created_at": "2024-12-23T03:00:00Z",
},
],
"total": 2,
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list"])
assert result.exit_code == 0
assert "snap_123" in result.stdout
assert "snap_456" in result.stdout
assert "first snapshot" in result.stdout
assert "daily backup" in result.stdout
def test_list_snapshots_empty(self):
"""Test listing when no snapshots exist."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"snapshots": [], "total": 0}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list"])
assert result.exit_code == 0
assert "No snapshots found" in result.stdout
def test_list_snapshots_with_limit(self):
"""Test listing snapshots with custom limit."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"snapshots": [
{
"id": f"snap_{i}",
"name": f"snap-{i}",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
for i in range(20)
],
"total": 20,
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list", "--limit", "5"])
assert result.exit_code == 0
# Should show message about more snapshots available
assert "Showing 5 of 20" in result.stdout
class TestSnapshotDeleteCommand:
"""Tests for 'bm cloud snapshot delete' command."""
def test_delete_snapshot_success_with_force(self):
"""Test successful snapshot deletion with --force flag."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "delete", "snap_123", "--force"])
assert result.exit_code == 0
assert "deleted successfully" in result.stdout
def test_delete_snapshot_not_found(self):
"""Test deletion of non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "delete", "snap_nonexistent", "--force"]
)
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
def test_delete_snapshot_cancelled(self):
"""Test snapshot deletion cancelled by user."""
runner = CliRunner()
# Mock successful GET for snapshot details
mock_get_response = Mock(spec=httpx.Response)
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"id": "snap_123",
"description": "test snapshot",
"created_at": "2024-12-24T12:00:00Z",
}
call_count = 0
async def mock_make_api_request(*args, **kwargs):
nonlocal call_count
call_count += 1
method = kwargs.get("method", args[0] if args else None)
if method == "GET":
return mock_get_response
# Track unexpected calls
return mock_get_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
# Simulate user saying "n" to confirmation
result = runner.invoke(
app, ["cloud", "snapshot", "delete", "snap_123"], input="n\n"
)
assert result.exit_code == 0
assert "cancelled" in result.stdout
# Only one call should happen (GET for details), not the DELETE
assert call_count == 1
class TestSnapshotShowCommand:
"""Tests for 'bm cloud snapshot show' command."""
def test_show_snapshot_success(self):
"""Test showing snapshot details."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "snap_123",
"bucket_name": "tenant-abc",
"snapshot_version": "1703430000000",
"name": "test-snapshot",
"description": "test description",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_123"])
assert result.exit_code == 0
assert "snap_123" in result.stdout
assert "tenant-abc" in result.stdout
assert "test description" in result.stdout
def test_show_snapshot_not_found(self):
"""Test showing non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_nonexistent"])
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
class TestSnapshotBrowseCommand:
"""Tests for 'bm cloud snapshot browse' command."""
def test_browse_snapshot_success(self):
"""Test browsing snapshot contents."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "research/paper.md", "size": 4096, "last_modified": "2025-01-18T12:00:00Z"},
],
"prefix": "",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
assert result.exit_code == 0
assert "notes/project.md" in result.stdout
assert "notes/ideas.md" in result.stdout
assert "research/paper.md" in result.stdout
def test_browse_snapshot_with_prefix(self):
"""Test browsing snapshot with prefix filter."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
],
"prefix": "notes/",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
# Verify prefix is in the URL
url = args[1] if len(args) > 1 else kwargs.get("url", "")
assert "prefix=notes/" in url
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "browse", "snap_123", "--prefix", "notes/"]
)
assert result.exit_code == 0
def test_browse_snapshot_empty(self):
"""Test browsing snapshot with no files."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [],
"prefix": "",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
assert result.exit_code == 0
assert "No files found" in result.stdout
+6 -6
View File
@@ -470,7 +470,7 @@ async def full_entity(sample_entity, entity_repository, file_service, entity_ser
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Search_Entity",
folder="test",
directory="test",
entity_type="test",
content=dedent("""
## Observations
@@ -502,7 +502,7 @@ async def test_graph(
EntitySchema(
title="Deeper Entity",
entity_type="deeper",
folder="test",
directory="test",
content=dedent("""
# Deeper Entity
"""),
@@ -513,7 +513,7 @@ async def test_graph(
EntitySchema(
title="Deep Entity",
entity_type="deep",
folder="test",
directory="test",
content=dedent("""
# Deep Entity
- deeper_connection [[Deeper Entity]]
@@ -525,7 +525,7 @@ async def test_graph(
EntitySchema(
title="Connected Entity 2",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Connected Entity 2
- deep_connection [[Deep Entity]]
@@ -537,7 +537,7 @@ async def test_graph(
EntitySchema(
title="Connected Entity 1",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Connected Entity 1
- [note] Connected 1 note
@@ -550,7 +550,7 @@ async def test_graph(
EntitySchema(
title="Root",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Root Entity
- [note] Root note 1
+163
View File
@@ -0,0 +1,163 @@
"""Tests for markdown/utils.py - entity model conversion utilities."""
from datetime import datetime, timezone
from pathlib import Path
import pytest
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation
from basic_memory.markdown.utils import entity_model_from_markdown
from basic_memory.models import Entity
class TestEntityModelFromMarkdown:
"""Tests for entity_model_from_markdown function."""
def _create_markdown(
self,
title: str = "Test Entity",
entity_type: str = "note",
permalink: str = "test/test-entity",
created: datetime | None = None,
modified: datetime | None = None,
observations: list[Observation] | None = None,
) -> EntityMarkdown:
"""Helper to create test EntityMarkdown objects."""
now = datetime.now(timezone.utc)
return EntityMarkdown(
frontmatter=EntityFrontmatter(
title=title,
type=entity_type,
permalink=permalink,
),
content=f"# {title}\n\nTest content.",
observations=observations or [],
relations=[],
created=created or now,
modified=modified or now,
)
def test_new_entity_has_external_id(self):
"""Test that a new entity always gets an external_id set.
This is a regression test for GitHub issue #512 where SQLite failed
with NOT NULL constraint on external_id because SQLAlchemy's Python-side
default wasn't always evaluated.
"""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
entity = entity_model_from_markdown(file_path, markdown)
# external_id must be set (non-None, non-empty)
assert entity.external_id is not None
assert entity.external_id != ""
# Should be a valid UUID format (36 chars with hyphens)
assert len(entity.external_id) == 36
assert entity.external_id.count("-") == 4
def test_existing_entity_preserves_external_id(self):
"""Test that an existing entity's external_id is preserved."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with known external_id
existing_external_id = "12345678-1234-1234-1234-123456789012"
existing_entity = Entity()
existing_entity.external_id = existing_external_id
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should preserve the existing external_id
assert entity.external_id == existing_external_id
def test_entity_with_empty_external_id_gets_new_one(self):
"""Test that an entity with empty string external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with empty external_id
existing_entity = Entity()
existing_entity.external_id = ""
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_entity_with_none_external_id_gets_new_one(self):
"""Test that an entity with None external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with None external_id
existing_entity = Entity()
# Explicitly set to None to test this case
object.__setattr__(existing_entity, "external_id", None)
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_multiple_calls_generate_unique_ids(self):
"""Test that multiple new entities get unique external_ids."""
markdown1 = self._create_markdown(title="Entity 1", permalink="test/entity-1")
markdown2 = self._create_markdown(title="Entity 2", permalink="test/entity-2")
entity1 = entity_model_from_markdown(Path("test/entity-1.md"), markdown1)
entity2 = entity_model_from_markdown(Path("test/entity-2.md"), markdown2)
# Both should have external_ids
assert entity1.external_id is not None
assert entity2.external_id is not None
# They should be unique
assert entity1.external_id != entity2.external_id
def test_entity_model_fields_populated_with_external_id(self):
"""Test that entity fields are populated correctly, including external_id.
This is a basic sanity check that entity_model_from_markdown sets
the key fields we care about for the #512 fix.
"""
markdown = self._create_markdown(
title="My Test Entity",
entity_type="component",
permalink="components/my-test",
)
file_path = Path("components/my-test.md")
entity = entity_model_from_markdown(file_path, markdown, project_id=1)
# The key assertion: external_id must always be set
assert entity.external_id is not None
assert len(entity.external_id) == 36 # UUID format
# Verify file_path is set correctly (uses posix format)
assert entity.file_path == "components/my-test.md"
# Timestamps should be set from markdown
assert entity.created_at is not None
assert entity.updated_at is not None
def test_missing_dates_raises_error(self):
"""Test that missing created/modified dates raise ValueError."""
markdown = EntityMarkdown(
frontmatter=EntityFrontmatter(
title="Test",
type="note",
),
content="# Test",
observations=[],
relations=[],
created=None,
modified=None,
)
with pytest.raises(ValueError, match="Both created and modified dates are required"):
entity_model_from_markdown(Path("test.md"), markdown)
@@ -0,0 +1,79 @@
"""Tests for async_client force_local_mode functionality."""
import os
from basic_memory.mcp.async_client import _force_local_mode
class TestForceLocalMode:
"""Tests for _force_local_mode function."""
def test_returns_false_when_not_set(self):
"""Should return False when env var is not set."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
assert _force_local_mode() is False
def test_returns_true_for_true(self):
"""Should return True when env var is 'true'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_1(self):
"""Should return True when env var is '1'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "1"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_yes(self):
"""Should return True when env var is 'yes'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "yes"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_TRUE_uppercase(self):
"""Should return True when env var is 'TRUE' (case insensitive)."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "TRUE"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_false(self):
"""Should return False when env var is 'false'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "false"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_0(self):
"""Should return False when env var is '0'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "0"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_empty(self):
"""Should return False when env var is empty string."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = ""
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_random_string(self):
"""Should return False when env var is random string."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "random"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
+8 -8
View File
@@ -12,7 +12,7 @@ async def test_write_note_tags_yaml_format(app, project_config, test_project):
result = await write_note.fn(
project=test_project.name,
title="YAML Format Test",
folder="test",
directory="test",
content="Testing YAML tag formatting",
tags=["system", "overview", "reference"],
)
@@ -44,7 +44,7 @@ async def test_write_note_stringified_json_tags(app, project_config, test_projec
result = await write_note.fn(
project=test_project.name,
title="Stringified JSON Test",
folder="test",
directory="test",
content="Testing stringified JSON tag input",
tags='["python", "testing", "json"]', # Stringified JSON array
)
@@ -74,7 +74,7 @@ async def test_write_note_single_tag_yaml_format(app, project_config, test_proje
await write_note.fn(
project=test_project.name,
title="Single Tag Test",
folder="test",
directory="test",
content="Testing single tag formatting",
tags=["solo-tag"],
)
@@ -93,7 +93,7 @@ async def test_write_note_no_tags(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="No Tags Test",
folder="test",
directory="test",
content="Testing note without tags",
tags=None,
)
@@ -112,7 +112,7 @@ async def test_write_note_empty_tags_list(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="Empty Tags Test",
folder="test",
directory="test",
content="Testing empty tag list",
tags=[],
)
@@ -131,7 +131,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
await write_note.fn(
project=test_project.name,
title="Update Format Test",
folder="test",
directory="test",
content="Initial content",
tags=["initial", "tag"],
)
@@ -140,7 +140,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
result = await write_note.fn(
project=test_project.name,
title="Update Format Test",
folder="test",
directory="test",
content="Updated content",
tags=["updated", "new-tag", "format"],
)
@@ -173,7 +173,7 @@ async def test_complex_tags_yaml_format(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="Complex Tags Test",
folder="test",
directory="test",
content="Testing complex tag formats",
tags=["python-3.9", "api_integration", "v2.0", "nested/category", "under_score"],
)
@@ -64,7 +64,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_a = await write_note.fn(
project=test_project.name,
title="Node A",
folder="edge-cases",
directory="edge-cases",
content="# Node A\n\nOriginal content for Node A\n\n## Relations\n- links_to [[Node B]]",
)
@@ -81,7 +81,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_b = await write_note.fn(
project=test_project.name,
title="Node B",
folder="edge-cases",
directory="edge-cases",
content="# Node B\n\nContent for Node B",
)
@@ -93,7 +93,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_c = await write_note.fn(
project=test_project.name,
title="Node C",
folder="edge-cases",
directory="edge-cases",
content="# Node C\n\nContent for Node C\n\n## Relations\n- links_to [[Node A]]",
)
@@ -165,7 +165,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
result = await write_note.fn(
project=test_project.name,
title=title,
folder=folder,
directory=folder,
content=f"# {title}\n\nUnique content for {title}",
)
@@ -210,7 +210,7 @@ async def test_sequential_note_creation_preserves_all_files(app, test_project):
result = await write_note.fn(
project=test_project.name,
title=title,
folder="sequence-test",
directory="sequence-test",
content=content,
)
assert "# Created note" in result or "# Updated note" in result
+8 -6
View File
@@ -153,8 +153,9 @@ async def test_recent_activity_prompt_discovery_mode(client, test_project, test_
result = await recent_activity_prompt.fn(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected discovery mode content
assert "Recent Activity Across All Projects" in result # pyright: ignore [reportOperatorIssue]
assert "Cross-Project Activity Discovery" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
assert "all projects" in result # pyright: ignore [reportOperatorIssue]
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
@@ -165,9 +166,9 @@ async def test_recent_activity_prompt_project_specific(client, test_project, tes
result = await recent_activity_prompt.fn(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected project-specific content
assert f"Recent Activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
assert "Opportunity to Capture Activity Summary" in result # pyright: ignore [reportOperatorIssue]
assert f"recent activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
assert test_project.name in result # pyright: ignore [reportOperatorIssue]
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
@@ -178,4 +179,5 @@ async def test_recent_activity_prompt_with_custom_timeframe(client, test_project
result = await recent_activity_prompt.fn(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues]
# Check the response includes the custom timeframe
assert "Recent Activity Across All Projects (1d)" in result # pyright: ignore [reportOperatorIssue]
assert "1d" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]

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