mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f962fdd87 | |||
| fbb497f6dc | |||
| 0023e736ab | |||
| 0b2080114b | |||
| 8730067f3a | |||
| e14ba92631 | |||
| 9d98892570 | |||
| 3be4495723 | |||
| 17c0e0a29b | |||
| 7ebf16a95d | |||
| c05075f8d4 | |||
| 4cef9281ca | |||
| 6888effef2 | |||
| 38616c345d | |||
| f3c1aa895c | |||
| d978aba09b | |||
| 2aaee734c9 | |||
| 369ad37b3d | |||
| 4e5f701d22 | |||
| 9d9ea4d61c | |||
| 7a502e6474 | |||
| c7835a9d5c | |||
| 85835ae533 |
@@ -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)
|
||||
@@ -54,3 +54,4 @@ ENV/
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
.mcpregistry_*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://badge.fury.io/py/basic-memory)
|
||||
[](https://www.python.org/downloads/)
|
||||
@@ -5,7 +6,6 @@
|
||||
[](https://github.com/astral-sh/ruff)
|
||||

|
||||

|
||||
[](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
|
||||
|
||||
@@ -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
|
||||
|
||||
### What’s 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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]")
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
telemetry,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]})
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,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}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"}),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ async def test_reindex(
|
||||
await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="TestEntity1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user