Compare commits

...

19 Commits

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-01-19 12:23:29 -06:00
98 changed files with 5751 additions and 1034 deletions
+1
View File
@@ -54,3 +54,4 @@ ENV/
claude-output
**/.claude/settings.local.json
.mcp.json
.mcpregistry_*
+77
View File
@@ -1,5 +1,82 @@
# CHANGELOG
## v0.18.0 (2026-01-28)
### Features
- **#527**: Add context-aware wiki link resolution with source_path support
([`0023e73`](https://github.com/basicmachines-co/basic-memory/commit/0023e73))
- Add `source_path` parameter to `resolve_link()` for context-aware resolution
- Relative path resolution: `[[nested/note]]` from `folder/file.md``folder/nested/note.md`
- Proximity-based resolution for duplicate titles (prefers notes in same folder)
- Strict mode to disable fuzzy search fallback for wiki links
- **#518**: Add directory support to move_note and delete_note tools
([`0b20801`](https://github.com/basicmachines-co/basic-memory/commit/0b20801))
- Add `is_directory` parameter to `move_note` and `delete_note` MCP tools
- New `POST /move-directory` and delete directory API endpoints
- Rename `folder``directory` parameter across codebase for consistency
- **#522**: Local MCP cloud mode routing
([`8730067`](https://github.com/basicmachines-co/basic-memory/commit/8730067))
- Add `--local` and `--cloud` CLI routing flags
- Local MCP server (`basic-memory mcp`) automatically uses local routing
- Enables simultaneous use of local Claude Desktop and cloud-based clients
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` for global override
### Bug Fixes
- **#524**: Fix MCP prompt rendering errors
([`e14ba92`](https://github.com/basicmachines-co/basic-memory/commit/e14ba92))
- Fix "Error rendering prompt recent_activity" error
- Change `TimeFrame` to `str` in prompt type annotations for FastMCP compatibility
## v0.17.9 (2026-01-24)
### Bug Fixes
- **#523**: Fix `remove_project()` checking stale config in cloud mode
([`17c0e0a`](https://github.com/basicmachines-co/basic-memory/commit/17c0e0a))
- In cloud mode, only check database `is_default` field (source of truth)
- Config file can become stale when users set default project via v2 API
## v0.17.8 (2026-01-24)
### Bug Fixes
- **#521**: Fix `get_default_project()` returning multiple results
([`6888eff`](https://github.com/basicmachines-co/basic-memory/commit/6888eff))
- Query incorrectly matched any project with non-NULL `is_default` (both True and False)
- Now correctly checks for `is_default=True` only
## v0.17.7 (2026-01-24)
### Features
- **#476**: Add SPEC-29 Phase 3 bucket snapshot CLI commands
([`369ad37`](https://github.com/basicmachines-co/basic-memory/commit/369ad37))
- New `basic-memory cloud snapshot` commands for managing cloud snapshots
- Commands: `create`, `list`, `delete`, `show`, `browse`
- **#515**: Add MCP registry publication files
([`7a502e6`](https://github.com/basicmachines-co/basic-memory/commit/7a502e6))
### Bug Fixes
- **#520**: Read default project from database in cloud mode
([`38616c3`](https://github.com/basicmachines-co/basic-memory/commit/38616c3))
- **#513**: Ensure external_id is set on entity creation
([`c7835a9`](https://github.com/basicmachines-co/basic-memory/commit/c7835a9))
### Internal
- **#514**: Remove OpenPanel telemetry
([`85835ae`](https://github.com/basicmachines-co/basic-memory/commit/85835ae))
- Update README links to point to basicmemory.com
([`2aaee73`](https://github.com/basicmachines-co/basic-memory/commit/2aaee73))
## v0.17.6 (2026-01-17)
### Bug Fixes
+26 -4
View File
@@ -240,19 +240,21 @@ See SPEC-16 for full context manager refactor details.
- Logout: `basic-memory cloud logout`
- Check cloud status: `basic-memory cloud status`
- Setup cloud sync: `basic-memory cloud setup`
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
**Content Management:**
- `write_note(title, content, folder, tags)` - Create/update markdown notes with semantic observations and relations
- `write_note(title, content, directory, tags)` - Create/update markdown notes with semantic observations and relations
- `read_note(identifier, page, page_size)` - Read notes by title, permalink, or memory:// URL with knowledge graph awareness
- `read_content(path)` - Read raw file content (text, images, binaries) without knowledge graph processing
- `view_note(identifier, page, page_size)` - View notes as formatted artifacts for better readability
- `edit_note(identifier, operation, content)` - Edit notes incrementally (append, prepend, find/replace, replace_section)
- `move_note(identifier, destination_path)` - Move notes to new locations, updating database and maintaining links
- `delete_note(identifier)` - Delete notes from the knowledge base
- `move_note(identifier, destination_path, is_directory)` - Move notes or directories to new locations, updating database and maintaining links
- `delete_note(identifier, is_directory)` - Delete notes or directories from the knowledge base
**Knowledge Graph Navigation:**
- `build_context(url, depth, timeframe)` - Navigate the knowledge graph via memory:// URLs for conversation continuity
@@ -268,7 +270,7 @@ See SPEC-16 for full context manager refactor details.
- `delete_project(project_name)` - Delete a project from configuration
**Visualization:**
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
- `canvas(nodes, edges, title, directory)` - Generate Obsidian canvas files for knowledge graph visualization
**ChatGPT-Compatible Tools:**
- `search(query)` - Search across knowledge base (OpenAI actions compatible)
@@ -308,6 +310,26 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
- Background relation resolution (non-blocking startup)
- API performance optimizations (SPEC-11)
**CLI Routing Flags:**
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
```bash
# Force local routing (ignore cloud mode)
basic-memory status --local
basic-memory project list --local
# Force cloud routing (when cloud mode is disabled)
basic-memory status --cloud
basic-memory project info my-project --cloud
```
Key behaviors:
- The local MCP server (`basic-memory mcp`) automatically uses local routing
- This allows simultaneous use of local Claude Desktop and cloud-based clients
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
## AI-Human Collaborative Development
Basic Memory emerged from and enables a new kind of development process that combines human and AI capabilities. Instead
+21 -23
View File
@@ -6,7 +6,6 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
![](https://badge.mcpx.dev?type=server 'MCP Server')
![](https://badge.mcpx.dev?type=dev 'MCP Dev')
[![smithery badge](https://smithery.ai/badge/@basicmachines-co/basic-memory)](https://smithery.ai/server/@basicmachines-co/basic-memory)
## 🚀 Basic Memory Cloud is Live!
@@ -14,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
@@ -24,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
@@ -63,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
@@ -376,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:**
@@ -426,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/)
@@ -452,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
+6 -3
View File
@@ -6,14 +6,17 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.17.7",
"version": "0.18.0",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.17.7",
"version": "0.18.0",
"runtimeHint": "uvx",
"runtimeArguments": ["basic-memory", "mcp"],
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
{"type": "positional", "value": "mcp"}
],
"transport": {
"type": "stdio"
}
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.17.7"
__version__ = "0.18.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+12 -12
View File
@@ -27,13 +27,13 @@ router = APIRouter(prefix="/import", tags=["import"])
async def import_chatgpt(
importer: ChatGPTImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from ChatGPT JSON export.
Args:
file: The ChatGPT conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -42,20 +42,20 @@ async def import_chatgpt(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/conversations", response_model=ChatImportResult)
async def import_claude_conversations(
importer: ClaudeConversationsImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> ChatImportResult:
"""Import conversations from Claude conversations.json export.
Args:
file: The Claude conversations.json file.
folder: The folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -64,20 +64,20 @@ async def import_claude_conversations(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/claude/projects", response_model=ProjectImportResult)
async def import_claude_projects(
importer: ClaudeProjectsImporterDep,
file: UploadFile,
folder: str = Form("projects"),
directory: str = Form("projects"),
) -> ProjectImportResult:
"""Import projects from Claude projects.json export.
Args:
file: The Claude projects.json file.
base_folder: The base folder to place the files in.
directory: The directory to place the files in.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -86,20 +86,20 @@ async def import_claude_projects(
Raises:
HTTPException: If import fails.
"""
return await import_file(importer, file, folder)
return await import_file(importer, file, directory)
@router.post("/memory-json", response_model=EntityImportResult)
async def import_memory_json(
importer: MemoryJsonImporterDep,
file: UploadFile,
folder: str = Form("conversations"),
directory: str = Form("conversations"),
) -> EntityImportResult:
"""Import entities and relations from a memory.json file.
Args:
file: The memory.json file.
destination_folder: Optional destination folder within the project.
directory: Optional destination directory within the project.
markdown_processor: MarkdownProcessor instance.
Returns:
@@ -116,7 +116,7 @@ async def import_memory_json(
json_data = json.loads(line)
file_data.append(json_data)
result = await importer.import_data(file_data, folder)
result = await importer.import_data(file_data, directory)
if not result.success: # pragma: no cover
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -29,7 +29,8 @@ from basic_memory.schemas import (
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest, MoveDirectoryRequest
from basic_memory.schemas.response import DirectoryMoveResult
from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(
@@ -231,6 +232,50 @@ async def move_entity(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/move-directory")
async def move_directory(
data: MoveDirectoryRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
project_config: ProjectConfigDep,
app_config: AppConfigDep,
search_service: SearchServiceDep,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
This endpoint moves all files within a source directory to a destination
directory, updating database records and optionally updating permalinks.
"""
logger.info(
f"API request: endpoint='move_directory', source='{data.source_directory}', destination='{data.destination_directory}'"
)
try:
# Move the directory using the service
result = await entity_service.move_directory(
source_directory=data.source_directory,
destination_directory=data.destination_directory,
project_config=project_config,
app_config=app_config,
)
# Reindex moved entities
for file_path in result.moved_files:
entity = await entity_service.link_resolver.resolve_link(file_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
logger.info(
f"API response: endpoint='move_directory', "
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
)
return result
except Exception as e:
logger.error(f"Error moving directory: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Read endpoints
+15 -3
View File
@@ -322,7 +322,14 @@ async def remove_project(
) # pragma: no cover
# Check if trying to delete the default project
if name == project_service.default_project:
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_project_name = db_default.name if db_default else config_default
if name == default_project_name:
available_projects = await project_service.list_projects()
other_projects = [p.name for p in available_projects if p.name != name]
detail = f"Cannot delete default project '{name}'. "
@@ -418,8 +425,13 @@ async def get_default_project(
Returns:
Response with project default information
"""
# Get the old default project
default_name = project_service.default_project
# Get the default project
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_name = db_default.name if db_default else config_default
default_project = await project_service.get_project(default_name)
if not default_project: # pragma: no cover
raise HTTPException( # pragma: no cover
@@ -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))
@@ -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())
+52 -48
View File
@@ -17,60 +17,64 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
from loguru import logger
config = ConfigManager().config
if not config.cloud_mode_enabled:
@app.command()
def mcp(
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
@app.command()
def mcp(
transport: str = typer.Option(
"stdio", help="Transport type: stdio, streamable-http, or sse"
),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
This command starts an MCP server using one of three transport options:
This command starts an MCP server using one of three transport options:
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
"""
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
Note: This command is available regardless of cloud mode setting.
Users who have cloud mode enabled can still use local MCP for Claude Code
and Claude Desktop while using cloud MCP for web and mobile access.
"""
# Force local routing for local MCP server
# Why: The local MCP server should always talk to the local API, not the cloud proxy.
# Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access.
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
+136 -44
View File
@@ -1,32 +1,32 @@
"""Command module for basic-memory project management."""
import json
import os
from datetime import datetime
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
import json
from datetime import datetime
from rich.panel import Panel
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.utils import generate_permalink, normalize_project_path
# Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import (
SyncProject,
RcloneError,
project_sync,
SyncProject,
project_bisync,
project_check,
project_ls,
project_sync,
)
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
@@ -46,8 +46,22 @@ def format_path(path: str) -> str:
@project_app.command("list")
def list_projects() -> None:
"""List all Basic Memory projects."""
def list_projects(
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""List all Basic Memory projects.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
async def _list_projects():
async with get_client() as client:
@@ -55,19 +69,20 @@ def list_projects() -> None:
return ProjectList.model_validate(response.json())
try:
result = run_with_cleanup(_list_projects())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_list_projects())
config = ConfigManager().config
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
# Add Local Path column if in cloud mode
if config.cloud_mode_enabled:
# Add Local Path column if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
show_default_column = not config.cloud_mode_enabled or config.default_project_mode
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
if show_default_column:
table.add_column("Default", style="magenta")
@@ -78,8 +93,8 @@ def list_projects() -> None:
# Build row based on mode
row = [project.name, format_path(normalized_path)]
# Add local path if in cloud mode
if config.cloud_mode_enabled:
# Add local path if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
local_path = ""
if project.name in config.cloud_projects:
local_path = config.cloud_projects[project.name].local_path or ""
@@ -108,9 +123,16 @@ def add_project(
None, "--local-path", help="Local sync path for cloud mode (optional)"
),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Add a new project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
Cloud mode examples:\n
bm project add research # No local sync\n
bm project add research --local-path ~/docs # With local sync\n
@@ -118,14 +140,23 @@ def add_project(
Local mode example:\n
bm project add research ~/Documents/research
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
config = ConfigManager().config
# Determine effective mode: local flag forces local mode behavior
effective_cloud_mode = config.cloud_mode_enabled and not local
# Resolve local sync path early (needed for both cloud and local mode)
local_sync_path: str | None = None
if local_path:
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
if config.cloud_mode_enabled:
if effective_cloud_mode:
# Cloud mode: path auto-generated from name, local sync is optional
async def _add_project():
@@ -154,11 +185,12 @@ def add_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_add_project())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_add_project())
console.print(f"[green]{result.message}[/green]")
# Save local sync path to config if in cloud mode
if config.cloud_mode_enabled and local_sync_path:
if effective_cloud_mode and local_sync_path:
from basic_memory.config import CloudProjectConfig
# Create local directory if it doesn't exist
@@ -243,8 +275,21 @@ def remove_project(
delete_notes: bool = typer.Option(
False, "--delete-notes", help="Delete project files from disk"
),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Remove a project."""
"""Remove a project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
async def _remove_project():
async with get_client() as client:
@@ -265,11 +310,11 @@ def remove_project(
try:
# Get config to check for local sync path and bisync state
config = ConfigManager().config
local_path = None
local_path_config = None
has_bisync_state = False
if config.cloud_mode_enabled and name in config.cloud_projects:
local_path = config.cloud_projects[name].local_path
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
local_path_config = config.cloud_projects[name].local_path
# Check for bisync state
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
@@ -278,17 +323,18 @@ def remove_project(
has_bisync_state = bisync_state_path.exists()
# Remove project from cloud/API
result = run_with_cleanup(_remove_project())
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(_remove_project())
console.print(f"[green]{result.message}[/green]")
# Clean up local sync directory if it exists and delete_notes is True
if delete_notes and local_path:
local_dir = Path(local_path)
if delete_notes and local_path_config:
local_dir = Path(local_path_config)
if local_dir.exists():
import shutil
shutil.rmtree(local_dir)
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
console.print(f"[green]Removed local sync directory: {local_path_config}[/green]")
# Clean up bisync state if it exists
if has_bisync_state:
@@ -301,14 +347,14 @@ def remove_project(
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and name in config.cloud_projects:
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
del config.cloud_projects[name]
ConfigManager().save_config(config)
# Show informative message if files were not deleted
if not delete_notes:
if local_path:
console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
if local_path_config:
console.print(f"[yellow]Note: Local files remain at {local_path_config}[/yellow]")
except Exception as e:
console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -318,15 +364,24 @@ def remove_project(
@project_app.command("default")
def set_default_project(
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Set the default project when 'config.default_project_mode' is set.
Note: This command is only available in local mode.
In cloud mode, use --local to modify the local configuration.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: default project is a local configuration concept
# Outcome: require explicit --local flag to modify local config in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'default' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project default <name> --local' to set local default[/yellow]"
)
raise typer.Exit(1)
async def _set_default():
@@ -346,7 +401,8 @@ def set_default_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_set_default())
with force_routing(local=local):
result = run_with_cleanup(_set_default())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -354,15 +410,25 @@ def set_default_project(
@project_app.command("sync-config")
def synchronize_projects() -> None:
def synchronize_projects(
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Synchronize project config between configuration file and database.
Note: This command is only available in local mode.
In cloud mode, use --local to sync local configuration.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: sync-config syncs local config file with local database
# Outcome: require explicit --local flag to clarify intent in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'sync-config' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project sync-config --local' to sync local config[/yellow]"
)
raise typer.Exit(1)
async def _sync_config():
@@ -371,7 +437,8 @@ def synchronize_projects() -> None:
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_sync_config())
with force_routing(local=local):
result = run_with_cleanup(_sync_config())
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -382,15 +449,24 @@ def synchronize_projects() -> None:
def move_project(
name: str = typer.Argument(..., help="Name of the project to move"),
new_path: str = typer.Argument(..., help="New absolute path for the project"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Move a project to a new location.
Note: This command is only available in local mode.
In cloud mode, use --local to modify local project paths.
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
# Trigger: cloud mode enabled without --local flag
# Why: moving a project is a local file system operation
# Outcome: require explicit --local flag to clarify intent in cloud mode
if config.cloud_mode_enabled and not local:
console.print(
"[red]Error: 'move' command requires --local flag in cloud mode[/red]\n"
"[yellow]Hint: Use 'bm project move <name> <path> --local' to move local project[/yellow]"
)
raise typer.Exit(1)
# Resolve to absolute path
@@ -406,7 +482,8 @@ def move_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = run_with_cleanup(_move_project())
with force_routing(local=local):
result = run_with_cleanup(_move_project())
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
@@ -779,11 +856,26 @@ def ls_project_command(
def display_project_info(
name: str = typer.Argument(..., help="Name of the project"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Display detailed information and statistics about the current project."""
"""Display detailed information and statistics about the current project.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
try:
# Get project info
info = run_with_cleanup(get_project_info(name))
with force_routing(local=local, cloud=cloud):
info = run_with_cleanup(get_project_info(name))
if json_output:
# Convert to JSON and print
+73
View File
@@ -0,0 +1,73 @@
"""CLI routing utilities for --local/--cloud flag handling.
This module provides utilities for CLI commands to override the default routing
behavior (determined by cloud_mode_enabled in config). This allows users to:
1. Use local MCP server even when cloud mode is enabled
2. Force local routing for specific CLI commands with --local flag
3. Force cloud routing with --cloud flag (requires authentication)
The routing is controlled via environment variables:
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
- These are checked in basic_memory.mcp.async_client.get_client()
"""
import os
from contextlib import contextmanager
from typing import Generator
@contextmanager
def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, None, None]:
"""Context manager to temporarily override routing mode.
Sets environment variables that are checked by get_client() to determine
whether to use local ASGI transport or cloud proxy transport.
Args:
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
cloud: If True, clear force_local to allow cloud routing
Usage:
with force_routing(local=True):
# All API calls will use local ASGI transport
await some_api_call()
Raises:
ValueError: If both local and cloud are True
"""
if local and cloud:
raise ValueError("Cannot specify both --local and --cloud")
# Save original values
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
try:
if local:
# Force local routing by setting the env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
elif cloud:
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
# If neither is set, don't change anything (use default behavior)
yield
finally:
# Restore original value
if original_force_local is None:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
else:
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
def validate_routing_flags(local: bool, cloud: bool) -> None:
"""Validate that --local and --cloud flags are not both specified.
Args:
local: Value of --local flag
cloud: Value of --cloud flag
Raises:
ValueError: If both flags are True
"""
if local and cloud:
raise ValueError("Cannot specify both --local and --cloud flags")
+16 -2
View File
@@ -11,6 +11,7 @@ from rich.panel import Panel
from rich.tree import Tree
from basic_memory.cli.app import app
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas import SyncReportResponse
@@ -162,12 +163,25 @@ def status(
typer.Option(help="The project name."),
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Show sync status between files and database."""
"""Show sync status between files and database.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
try:
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except Exception as e:
logger.error(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
+156 -74
View File
@@ -9,6 +9,7 @@ from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
# Import prompts
@@ -50,6 +51,10 @@ def write_note(
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Create or update a markdown note. Content can be provided as an argument or read from stdin.
@@ -57,6 +62,9 @@ def write_note(
1. Using the --content parameter
2. Piping content through stdin (if --content is not provided)
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
Examples:
# Using content parameter
@@ -77,8 +85,13 @@ def write_note(
# Reading from a file
cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
# Force local routing in cloud mode
basic-memory tools write-note --title "My Note" --folder "notes" --content "..." --local
"""
try:
validate_routing_flags(local, cloud)
# If content is not provided, read from stdin
if content is None:
# Check if we're getting data from a pipe or redirect
@@ -109,8 +122,12 @@ def write_note(
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
with force_routing(local=local, cloud=cloud):
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
rprint(note)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during write_note: {e}", err=True)
@@ -129,24 +146,37 @@ def read_note(
] = None,
page: int = 1,
page_size: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Read a markdown note from the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
"""Read a markdown note from the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
rprint(note)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during read_note: {e}", err=True)
@@ -166,38 +196,51 @@ def build_context(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get context needed to continue a discussion."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
"""Get context needed to continue a discussion.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
context = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
context = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
)
# Use json module for more controlled serialization
import json
context_dict = context.model_dump(exclude_none=True)
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
@@ -210,18 +253,32 @@ def recent_activity(
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get recent activity across the knowledge base."""
"""Get recent activity across the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
)
)
)
# The tool now returns a formatted string directly
print(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during recent_activity: {e}", err=True)
@@ -246,26 +303,31 @@ def search_notes(
] = None,
page: int = 1,
page_size: int = 10,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Search across all content in the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
if permalink and title: # pragma: no cover
print("Cannot search both permalink and title")
raise typer.Abort()
"""Search across all content in the knowledge base.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
if permalink and title: # pragma: no cover
typer.echo(
"Use either --permalink or --title, not both. Exiting.",
@@ -279,21 +341,25 @@ def search_notes(
search_type = ("title" if title else None,)
search_type = "text" if search_type is None else search_type
results = run_with_cleanup(
mcp_search.fn(
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
with force_routing(local=local, cloud=cloud):
results = run_with_cleanup(
mcp_search.fn(
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
)
)
)
# Use json module for more controlled serialization
import json
results_dict = results.model_dump(exclude_none=True)
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error during search", e)
@@ -308,12 +374,28 @@ def continue_conversation(
timeframe: Annotated[
Optional[str], typer.Option(help="How far back to look for activity")
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Prompt to continue a previous conversation or work session."""
"""Prompt to continue a previous conversation or work session.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
# Prompt functions return formatted strings directly
session = run_with_cleanup(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
# Prompt functions return formatted strings directly
session = run_with_cleanup(
mcp_continue_conversation.fn(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
)
rprint(session)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error continuing conversation", e)
+4 -4
View File
@@ -450,16 +450,16 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
return text.strip(replacement)
def sanitize_for_folder(folder: str) -> str:
def sanitize_for_directory(directory: str) -> str:
"""
Sanitize folder path to be safe for use in file system paths.
Sanitize directory path to be safe for use in file system paths.
Removes leading/trailing whitespace, compresses multiple slashes,
and removes special characters except for /, -, and _.
"""
if not folder:
if not directory:
return ""
sanitized = folder.strip()
sanitized = directory.strip()
if sanitized.startswith("./"):
sanitized = sanitized[2:]
+32 -2
View File
@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager, AbstractAsyncContextManager
from typing import AsyncIterator, Callable, Optional
@@ -8,6 +9,19 @@ from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
def _force_local_mode() -> bool:
"""Check if local mode is forced via environment variable.
This allows commands like `bm mcp` to force local routing even when
cloud_mode_enabled is True in config. The local MCP server should
always talk to the local API, not the cloud proxy.
Returns:
True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value
"""
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
# Optional factory override for dependency injection
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
@@ -71,7 +85,17 @@ async def get_client() -> AsyncIterator[AsyncClient]:
pool=30.0, # 30 seconds for connection pool
)
if config.cloud_mode_enabled:
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
# Why: allows local MCP server and CLI commands to route locally
# even when cloud_mode_enabled is True
# Outcome: uses ASGI transport for in-process local API calls
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
elif config.cloud_mode_enabled:
# CLI cloud mode: inject auth when creating client
from basic_memory.cli.auth import CLIAuth
@@ -126,7 +150,13 @@ def create_client() -> AsyncClient:
pool=30.0, # 30 seconds for connection pool
)
if config.cloud_mode_enabled:
# Check force local first (for local MCP server and CLI --local flag)
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
elif config.cloud_mode_enabled:
# Use HTTP transport to proxy endpoint
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
+50 -1
View File
@@ -8,7 +8,12 @@ from typing import Any
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
from basic_memory.schemas.response import (
EntityResponse,
DeleteEntitiesResponse,
DirectoryMoveResult,
DirectoryDeleteResult,
)
class KnowledgeClient:
@@ -153,6 +158,50 @@ class KnowledgeClient:
)
return EntityResponse.model_validate(response.json())
async def move_directory(
self, source_directory: str, destination_directory: str
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
Args:
source_directory: Source directory path (relative to project root)
destination_directory: Destination directory path (relative to project root)
Returns:
DirectoryMoveResult with counts and details of moved files
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"{self._base_path}/move-directory",
json={
"source_directory": source_directory,
"destination_directory": destination_directory,
},
)
return DirectoryMoveResult.model_validate(response.json())
async def delete_directory(self, directory: str) -> DirectoryDeleteResult:
"""Delete all entities in a directory.
Args:
directory: Directory path to delete (relative to project root)
Returns:
DirectoryDeleteResult with counts and details of deleted files
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"{self._base_path}/delete-directory",
json={"directory": directory},
)
return DirectoryDeleteResult.model_validate(response.json())
# --- Resolution ---
async def resolve_entity(self, identifier: str) -> str:
@@ -13,7 +13,6 @@ from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.prompt import ContinueConversationRequest
@@ -24,7 +23,7 @@ from basic_memory.schemas.prompt import ContinueConversationRequest
async def continue_conversation(
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[TimeFrame],
Optional[str],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
+47 -135
View File
@@ -3,17 +3,14 @@
These prompts help users see what has changed in their knowledge base recently.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext, ProjectActivitySummary
from basic_memory.schemas.search import SearchItemType
@mcp.prompt(
@@ -22,7 +19,7 @@ from basic_memory.schemas.search import SearchItemType
)
async def recent_activity_prompt(
timeframe: Annotated[
TimeFrame,
str,
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = "7d",
project: Annotated[
@@ -47,142 +44,57 @@ async def recent_activity_prompt(
"""
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
recent = await recent_activity.fn(
project=project, timeframe=timeframe, type=[SearchItemType.ENTITY]
# Call the tool function - it returns a well-formatted string
# Pass type as string values (not enum) to match the tool's expected input
activity_summary = await recent_activity.fn(
project=project, timeframe=timeframe, type="entity"
)
# Extract primary results from the hierarchical structure
primary_results = []
related_results = []
# Build the prompt response
# The tool already returns formatted markdown, so we use it directly
# and add prompt-specific guidance
target = project if project else "all projects"
if isinstance(recent, ProjectActivitySummary):
# Discovery mode - extract results from all projects
for _, project_activity in recent.projects.items():
if project_activity.activity.results:
# Take up to 2 primary results per project
for item in project_activity.activity.results[:2]:
primary_results.append(item.primary_result)
# Add up to 1 related result per primary item
if item.related_results:
related_results.extend(item.related_results[:1]) # pragma: no cover
prompt_guidance = dedent(f"""
# Recent Activity Context
# Limit total results for readability
primary_results = primary_results[:8]
related_results = related_results[:6]
This is a memory retrieval session showing recent activity from {target}.
elif isinstance(recent, GraphContext):
# Project-specific mode - use existing logic
if recent.results:
# Take up to 5 primary results
for item in recent.results[:5]:
primary_results.append(item.primary_result)
# Add up to 2 related results per primary item
if item.related_results:
related_results.extend(item.related_results[:2]) # pragma: no cover
{activity_summary}
# Set topic based on mode
if project:
topic = f"Recent Activity in {project} ({timeframe})"
else:
topic = f"Recent Activity Across All Projects ({timeframe})"
---
prompt_context = format_prompt_context(
PromptContext(
topic=topic,
timeframe=timeframe,
results=[
PromptContextItem(
primary_results=primary_results,
related_results=related_results[:10], # Limit total related results
)
],
## Next Steps
Based on this activity, you can:
1. **Explore specific items** - Use `read_note("permalink")` to dive deeper into any item
2. **Search for related content** - Use `search_notes("topic")` to find connected knowledge
3. **Build context** - Use `build_context("memory://path")` to see relationships
## Capture Opportunity
If you notice patterns or insights from this activity, consider documenting them:
```python
write_note(
title="Activity Insights - {timeframe}",
content='''
# Activity Insights
## Patterns Observed
- [trend] [Pattern you noticed in the activity]
## Key Developments
- [insight] [Important development worth tracking]
## Relations
- summarizes [[Recent Work]]
''',
folder="insights",
project="{project or 'default'}"
)
)
```
""")
# Add mode-specific suggestions
first_title = "Recent Topic"
if primary_results and len(primary_results) > 0:
first_title = primary_results[0].title
if project:
# Project-specific suggestions
capture_suggestions = f"""
## Opportunity to Capture Activity Summary
Consider creating a summary note of recent activity in {project}:
```python
await write_note(
"{project}",
title="Activity Summary {timeframe}",
content='''
# Activity Summary for {project} ({timeframe})
## Overview
[Summary of key changes and developments in this project over this period]
## Key Updates
[List main updates and their significance within this project]
## Observations
- [trend] [Observation about patterns in recent activity]
- [insight] [Connection between different activities]
## Relations
- summarizes [[{first_title}]]
- relates_to [[{project} Overview]]
''',
folder="summaries"
)
```
Summarizing periodic activity helps create high-level insights and connections within the project.
"""
else:
# Discovery mode suggestions
project_count = len(recent.projects) if isinstance(recent, ProjectActivitySummary) else 0
most_active = (
getattr(recent.summary, "most_active_project", "Unknown")
if isinstance(recent, ProjectActivitySummary)
else "Unknown"
)
capture_suggestions = f"""
## Cross-Project Activity Discovery
Found activity across {project_count} projects. Most active: **{most_active}**
Consider creating a cross-project summary:
```python
await write_note(
"{most_active if most_active != "Unknown" else "main"}",
title="Cross-Project Activity Summary {timeframe}",
content='''
# Cross-Project Activity Summary ({timeframe})
## Overview
Activity found across {project_count} projects, with {most_active} showing the most activity.
## Key Developments
[Summarize important changes across all projects]
## Project Insights
[Note patterns or connections between projects]
## Observations
- [trend] [Cross-project patterns observed]
- [insight] [Connections between different project activities]
## Relations
- summarizes [[{first_title}]]
- relates_to [[Project Portfolio Overview]]
''',
folder="summaries"
)
```
Cross-project summaries help identify broader trends and project interconnections.
"""
return prompt_context + capture_suggestions
return prompt_guidance
+1 -2
View File
@@ -12,7 +12,6 @@ from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.prompt import SearchPromptRequest
@@ -23,7 +22,7 @@ from basic_memory.schemas.prompt import SearchPromptRequest
async def search_prompt(
query: str,
timeframe: Annotated[
Optional[TimeFrame],
Optional[str],
Field(description="How far back to search (e.g. '1d', '1 week')"),
] = None,
) -> str:
+1 -2
View File
@@ -8,7 +8,6 @@ from dataclasses import dataclass
from textwrap import dedent
from typing import List
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
normalize_memory_url,
EntitySummary,
@@ -25,7 +24,7 @@ class PromptContextItem:
@dataclass
class PromptContext:
timeframe: TimeFrame
timeframe: str
topic: str
results: List[PromptContextItem]
+11 -11
View File
@@ -22,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:
@@ -43,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:
@@ -52,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
@@ -66,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,
@@ -85,21 +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
"""
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}
+94 -14
View File
@@ -147,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")
@@ -193,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,6 +217,10 @@ async def delete_note(
commands and alternative formats to try.
"""
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
@@ -210,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)
+92 -11
View File
@@ -342,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
@@ -381,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,7 +413,9 @@ async def move_note(
- Maintains all observations and relations
"""
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)
@@ -426,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
)
+1 -1
View File
@@ -345,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)
+18 -18
View File
@@ -21,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",
@@ -57,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.
@@ -88,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"
)
@@ -96,45 +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
"""
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)
@@ -142,7 +142,7 @@ async def write_note(
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
directory=directory,
entity_type=note_type,
content_type="text/markdown",
content=content,
@@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any
from loguru import logger
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
@@ -69,12 +69,21 @@ class EntityRepository(Repository[Entity]):
return await self.find_one(query)
async def get_by_title(self, title: str) -> Sequence[Entity]:
"""Get entity by title.
"""Get entities by title, ordered by shortest path first.
When multiple entities share the same title (in different folders),
returns them ordered by file_path length then alphabetically.
This provides "shortest path" resolution for duplicate titles.
Args:
title: Title of the entity to find
"""
query = self.select().where(Entity.title == title).options(*self.get_load_options())
query = (
self.select()
.where(Entity.title == title)
.order_by(func.length(Entity.file_path), Entity.file_path)
.options(*self.get_load_options())
)
result = await self.execute_query(query)
return list(result.scalars().all())
@@ -88,7 +88,7 @@ class ProjectRepository(Repository[Project]):
async def get_default_project(self) -> Optional[Project]:
"""Get the default project (the one marked as is_default=True)."""
query = self.select().where(Project.is_default.is_not(None))
query = self.select().where(Project.is_default.is_(True))
return await self.find_one(query)
async def get_active_projects(self) -> Sequence[Project]:
+7 -5
View File
@@ -24,7 +24,7 @@ from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
from basic_memory.config import ConfigManager
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_directory
from basic_memory.utils import generate_permalink
@@ -227,7 +227,7 @@ class Entity(BaseModel):
title: str
content: Optional[str] = None
folder: str
directory: str
entity_type: EntityType = "note"
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
content_type: ContentType = Field(
@@ -237,7 +237,7 @@ class Entity(BaseModel):
)
def __init__(self, **data):
data["folder"] = sanitize_for_folder(data.get("folder", ""))
data["directory"] = sanitize_for_directory(data.get("directory", ""))
super().__init__(**data)
@property
@@ -272,10 +272,12 @@ class Entity(BaseModel):
safe_title = self.safe_title
if self.content_type == "text/markdown":
return (
os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
os.path.join(self.directory, f"{safe_title}.md")
if self.directory
else f"{safe_title}.md"
)
else:
return os.path.join(self.folder, safe_title) if self.folder else safe_title
return os.path.join(self.directory, safe_title) if self.directory else safe_title
@property
def permalink(self) -> Optional[Permalink]:
+24
View File
@@ -110,3 +110,27 @@ class MoveEntityRequest(BaseModel):
if not v.strip():
raise ValueError("destination_path cannot be empty or whitespace only")
return v.strip()
class MoveDirectoryRequest(BaseModel):
"""Request schema for moving an entire directory to a new location.
This moves all entities within a directory to a new location while
maintaining project consistency and updating database references.
"""
source_directory: Annotated[str, MinLen(1), MaxLen(500)]
destination_directory: Annotated[str, MinLen(1), MaxLen(500)]
project: Optional[str] = None
@field_validator("source_directory", "destination_directory")
@classmethod
def validate_directory_path(cls, v):
"""Ensure directory path is relative and valid."""
if v.startswith("/"): # pragma: no cover
raise ValueError("directory path must be relative, not absolute")
if ".." in v: # pragma: no cover
raise ValueError("directory path cannot contain '..' path components")
if not v.strip(): # pragma: no cover
raise ValueError("directory path cannot be empty or whitespace only")
return v.strip()
+68
View File
@@ -284,3 +284,71 @@ class DeleteEntitiesResponse(SQLAlchemyModel):
"""
deleted: bool
class DirectoryMoveError(BaseModel):
"""Error details for a failed file move within a directory move operation."""
path: str
error: str
class DirectoryDeleteError(BaseModel):
"""Error details for a failed file delete within a directory delete operation."""
path: str
error: str
class DirectoryMoveResult(SQLAlchemyModel):
"""Response schema for directory move operations.
Returns detailed results of moving all files within a directory,
including counts and any errors encountered.
Example Response:
{
"total_files": 5,
"successful_moves": 5,
"failed_moves": 0,
"moved_files": [
"docs/file1.md",
"docs/file2.md",
"docs/subdir/file3.md"
],
"errors": []
}
"""
total_files: int
successful_moves: int
failed_moves: int
moved_files: List[str] # List of file paths that were moved
errors: List[DirectoryMoveError] # List of errors for failed moves
class DirectoryDeleteResult(SQLAlchemyModel):
"""Response schema for directory delete operations.
Returns detailed results of deleting all files within a directory,
including counts and any errors encountered.
Example Response:
{
"total_files": 5,
"successful_deletes": 5,
"failed_deletes": 0,
"deleted_files": [
"docs/file1.md",
"docs/file2.md",
"docs/subdir/file3.md"
],
"errors": []
}
"""
total_files: int
successful_deletes: int
failed_deletes: int
deleted_files: List[str] # List of file paths that were deleted
errors: List[DirectoryDeleteError] # List of errors for failed deletes
+4
View File
@@ -5,6 +5,8 @@ from basic_memory.schemas.v2.entity import (
EntityResolveResponse,
EntityResponseV2,
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
ProjectResolveRequest,
ProjectResolveResponse,
)
@@ -19,6 +21,8 @@ __all__ = [
"EntityResolveResponse",
"EntityResponseV2",
"MoveEntityRequestV2",
"MoveDirectoryRequestV2",
"DeleteDirectoryRequestV2",
"ProjectResolveRequest",
"ProjectResolveResponse",
"CreateResourceRequest",
+48
View File
@@ -15,6 +15,9 @@ class EntityResolveRequest(BaseModel):
- Permalinks (e.g., "specs/search")
- Titles (e.g., "Search Specification")
- File paths (e.g., "specs/search.md")
When source_path is provided, resolution prefers notes closer to the source
(context-aware resolution for duplicate titles).
"""
identifier: str = Field(
@@ -23,6 +26,15 @@ class EntityResolveRequest(BaseModel):
min_length=1,
max_length=500,
)
source_path: Optional[str] = Field(
None,
description="Path of the source file containing the link (for context-aware resolution)",
max_length=500,
)
strict: bool = Field(
False,
description="If True, only exact matches are allowed (no fuzzy search fallback)",
)
class EntityResolveResponse(BaseModel):
@@ -56,6 +68,42 @@ class MoveEntityRequestV2(BaseModel):
)
class MoveDirectoryRequestV2(BaseModel):
"""V2 request schema for moving an entire directory to a new location.
This moves all entities within a source directory to a destination directory
while maintaining project consistency and updating database references.
"""
source_directory: str = Field(
...,
description="Source directory path (relative to project root)",
min_length=1,
max_length=500,
)
destination_directory: str = Field(
...,
description="Destination directory path (relative to project root)",
min_length=1,
max_length=500,
)
class DeleteDirectoryRequestV2(BaseModel):
"""V2 request schema for deleting all entities in a directory.
This deletes all entities within a directory, removing them from the
database and file system.
"""
directory: str = Field(
...,
description="Directory path to delete (relative to project root)",
min_length=1,
max_length=500,
)
class EntityResponseV2(BaseModel):
"""V2 entity response with external_id as the primary API identifier.
+175 -1
View File
@@ -26,6 +26,12 @@ from basic_memory.repository import ObservationRepository, RelationRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.schemas.base import Permalink
from basic_memory.schemas.response import (
DirectoryMoveResult,
DirectoryMoveError,
DirectoryDeleteResult,
DirectoryDeleteError,
)
from basic_memory.services import BaseService, FileService
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
from basic_memory.services.link_resolver import LinkResolver
@@ -191,7 +197,7 @@ class EntityService(BaseService[EntityModel]):
if await self.file_service.exists(file_path):
raise EntityCreationError(
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
)
# Parse content frontmatter to check for user-specified permalink and entity_type
@@ -862,3 +868,171 @@ class EntityService(BaseService[EntityModel]):
# Re-raise the original error with context
raise ValueError(f"Move failed: {str(e)}") from e
async def move_directory(
self,
source_directory: str,
destination_directory: str,
project_config: ProjectConfig,
app_config: BasicMemoryConfig,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
This operation moves all files within a source directory to a destination
directory, updating database records and search indexes. The operation
tracks successes and failures individually to provide detailed feedback.
Args:
source_directory: Source directory path relative to project root
destination_directory: Destination directory path relative to project root
project_config: Project configuration for file operations
app_config: App configuration for permalink update settings
Returns:
DirectoryMoveResult with counts and details of moved files
Raises:
ValueError: If source directory is empty or destination conflicts exist
"""
logger.info(f"Moving directory: {source_directory} -> {destination_directory}")
# Normalize directory paths (remove trailing slashes)
source_directory = source_directory.strip("/")
destination_directory = destination_directory.strip("/")
# Find all entities in the source directory
entities = await self.repository.find_by_directory_prefix(source_directory)
if not entities:
logger.warning(f"No entities found in directory: {source_directory}")
return DirectoryMoveResult(
total_files=0,
successful_moves=0,
failed_moves=0,
moved_files=[],
errors=[],
)
# Track results
moved_files: list[str] = []
errors: list[DirectoryMoveError] = []
successful_moves = 0
failed_moves = 0
# Process each entity
for entity in entities:
try:
# Calculate new path by replacing source prefix with destination
old_path = entity.file_path
# Replace only the first occurrence of the source directory prefix
if old_path.startswith(f"{source_directory}/"):
new_path = old_path.replace(f"{source_directory}/", f"{destination_directory}/", 1)
else: # pragma: no cover
# Entity is directly in the source directory (shouldn't happen with prefix match)
new_path = f"{destination_directory}/{old_path}"
# Move the individual entity
await self.move_entity(
identifier=entity.file_path,
destination_path=new_path,
project_config=project_config,
app_config=app_config,
)
moved_files.append(new_path)
successful_moves += 1
logger.debug(f"Moved entity: {old_path} -> {new_path}")
except Exception as e: # pragma: no cover
failed_moves += 1
errors.append(DirectoryMoveError(path=entity.file_path, error=str(e)))
logger.error(f"Failed to move entity {entity.file_path}: {e}")
logger.info(
f"Directory move complete: {successful_moves} succeeded, {failed_moves} failed "
f"(source={source_directory}, dest={destination_directory})"
)
return DirectoryMoveResult(
total_files=len(entities),
successful_moves=successful_moves,
failed_moves=failed_moves,
moved_files=moved_files,
errors=errors,
)
async def delete_directory(
self,
directory: str,
) -> DirectoryDeleteResult:
"""Delete all entities in a directory.
This operation deletes all files within a directory, updating database
records and search indexes. The operation tracks successes and failures
individually to provide detailed feedback.
Args:
directory: Directory path relative to project root
Returns:
DirectoryDeleteResult with counts and details of deleted files
"""
logger.info(f"Deleting directory: {directory}")
# Normalize directory path (remove trailing slashes)
directory = directory.strip("/")
# Find all entities in the directory
entities = await self.repository.find_by_directory_prefix(directory)
if not entities:
logger.warning(f"No entities found in directory: {directory}")
return DirectoryDeleteResult(
total_files=0,
successful_deletes=0,
failed_deletes=0,
deleted_files=[],
errors=[],
)
# Track results
deleted_files: list[str] = []
errors: list[DirectoryDeleteError] = []
successful_deletes = 0
failed_deletes = 0
# Process each entity
for entity in entities:
try:
file_path = entity.file_path
# Delete the entity (this handles file deletion and database cleanup)
deleted = await self.delete_entity(entity.id)
if deleted:
deleted_files.append(file_path)
successful_deletes += 1
logger.debug(f"Deleted entity: {file_path}")
else: # pragma: no cover
failed_deletes += 1
errors.append(DirectoryDeleteError(path=file_path, error="Delete returned False"))
logger.warning(f"Delete returned False for entity: {file_path}")
except Exception as e: # pragma: no cover
failed_deletes += 1
errors.append(DirectoryDeleteError(path=entity.file_path, error=str(e)))
logger.error(f"Failed to delete entity {entity.file_path}: {e}")
logger.info(
f"Directory delete complete: {successful_deletes} succeeded, {failed_deletes} failed "
f"(directory={directory})"
)
return DirectoryDeleteResult(
total_files=len(entities),
successful_deletes=successful_deletes,
failed_deletes=failed_deletes,
deleted_files=deleted_files,
errors=errors,
)
+134 -4
View File
@@ -28,7 +28,11 @@ class LinkResolver:
self.search_service = search_service
async def resolve_link(
self, link_text: str, use_search: bool = True, strict: bool = False
self,
link_text: str,
use_search: bool = True,
strict: bool = False,
source_path: Optional[str] = None,
) -> Optional[Entity]:
"""Resolve a markdown link to a permalink.
@@ -36,12 +40,69 @@ class LinkResolver:
link_text: The link text to resolve
use_search: Whether to use search-based fuzzy matching as fallback
strict: If True, only exact matches are allowed (no fuzzy search fallback)
source_path: Optional path of the source file containing the link.
Used to prefer notes closer to the source (context-aware resolution).
"""
logger.trace(f"Resolving link: {link_text}")
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
# Clean link text and extract any alias
clean_text, alias = self._normalize_link_text(link_text)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
# for cross-platform compatibility. See entity_repository.py which normalizes
# paths using Path().as_posix(). This allows consistent path operations here.
# --- Relative Path Resolution ---
# Trigger: source_path is provided AND link contains "/"
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
if source_path and "/" in clean_text:
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
if source_folder:
# Construct relative path from source folder
relative_path = f"{source_folder}/{clean_text}"
# Try with .md extension
if not relative_path.endswith(".md"):
relative_path_md = f"{relative_path}.md"
entity = await self.entity_repository.get_by_file_path(relative_path_md)
if entity:
return entity
# Try as-is (already has extension or is a permalink)
entity = await self.entity_repository.get_by_file_path(relative_path)
if entity:
return entity
# When source_path is provided, use context-aware resolution:
# Check both permalink and title matches, prefer closest to source.
# Example: [[testing]] from folder/note.md prefers folder/testing.md
# over a root testing.md with permalink "testing".
if source_path:
# Gather all potential matches
candidates: list[Entity] = []
# Check permalink match
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
if permalink_entity:
candidates.append(permalink_entity)
# Check title matches
title_entities = await self.entity_repository.get_by_title(clean_text)
for entity in title_entities:
# Avoid duplicates (permalink match might also be in title matches)
if entity.id not in [c.id for c in candidates]:
candidates.append(entity)
if candidates:
if len(candidates) == 1:
return candidates[0]
else:
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)
# Standard resolution (no source context): permalink first, then title
# 1. Try exact permalink match first (most efficient)
entity = await self.entity_repository.get_by_permalink(clean_text)
if entity:
@@ -51,7 +112,7 @@ class LinkResolver:
# 2. Try exact title match
found = await self.entity_repository.get_by_title(clean_text)
if found:
# Return first match if there are duplicates (consistent behavior)
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
@@ -108,7 +169,7 @@ class LinkResolver:
if text.startswith("[[") and text.endswith("]]"):
text = text[2:-2]
# Handle Obsidian-style aliases (format: [[actual|alias]])
# Handle wiki link aliases (format: [[actual|alias]])
alias = None
if "|" in text:
text, alias = text.split("|", 1)
@@ -119,3 +180,72 @@ class LinkResolver:
text = text.strip()
return text, alias
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
"""Find the entity closest to the source file path.
Context-aware resolution: prefer notes in the same folder or closer in hierarchy.
Proximity Scoring Algorithm:
- Priority 0: Same folder as source (best match)
- Priority 1-N: Ancestor folders (N = levels up from source)
- Priority 100+N: Descendant folders (N = levels down, deprioritized)
- Priority 1000: Completely unrelated paths (least preferred)
- Ties are broken by shortest absolute path (consistent behavior)
Args:
entities: List of entities with the same title
source_path: Path of the file containing the link
Returns:
The entity closest to the source path
"""
# Extract source folder (everything before the last /)
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
def path_proximity(entity: Entity) -> Tuple[int, int]:
"""Return (proximity_score, path_length) for sorting.
Lower is better for both values.
"""
entity_path = entity.file_path
entity_folder = entity_path.rsplit("/", 1)[0] if "/" in entity_path else ""
# Trigger: entity is in the same folder as source
# Why: same-folder notes are most contextually relevant
# Outcome: priority = 0 (best), ties broken by shortest path
if entity_folder == source_folder:
return (0, len(entity_path))
# Trigger: entity is in an ancestor folder of source
# e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor
# Why: ancestors are contextually relevant (shared parent context)
# Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred
if source_folder.startswith(entity_folder + "/") if entity_folder else source_folder:
# Count how many levels up
if entity_folder:
levels_up = source_folder.count("/") - entity_folder.count("/")
else:
# Root level
levels_up = source_folder.count("/") + 1
return (levels_up, len(entity_path))
# Trigger: entity is in a descendant folder of source
# e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant
# Why: descendants are less contextually relevant than ancestors
# Outcome: priority = 100 + levels_down, significantly deprioritized
if entity_folder.startswith(source_folder + "/") if source_folder else entity_folder:
if source_folder:
levels_down = entity_folder.count("/") - source_folder.count("/")
else:
# Source is at root
levels_down = entity_folder.count("/") + 1
return (100 + levels_down, len(entity_path))
# Trigger: entity is in a completely unrelated path
# Why: no folder relationship means minimal contextual relevance
# Outcome: priority = 1000, only selected if no related paths exist
return (1000, len(entity_path))
# Sort by proximity (lower is better), then by path length (shorter is better)
return min(entities, key=path_proximity)
+7 -2
View File
@@ -241,8 +241,13 @@ class ProjectService:
project_path = project.path
# Check if project is default (in cloud mode, check database; in local mode, check config)
if project.is_default or name == self.config_manager.config.default_project:
# Check if project is default
# In cloud mode: database is source of truth
# In local mode: also check config file
is_default = project.is_default
if not self.config_manager.config.cloud_mode:
is_default = is_default or name == self.config_manager.config.default_project
if is_default:
raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
# Remove from config if it exists there (may not exist in cloud mode)
+181
View File
@@ -0,0 +1,181 @@
"""Integration tests for CLI routing flags (--local/--cloud).
These tests verify that the --local and --cloud flags work correctly
across CLI commands, and that the MCP command forces local routing.
Note: Environment variable behavior during command execution is tested
in unit tests (tests/cli/test_routing.py) which can properly monkeypatch
the modules before they are imported. These integration tests focus on
CLI behavior: flag acceptance and error handling.
"""
import os
import pytest
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
class TestRoutingFlagsValidation:
"""Tests for --local/--cloud flag validation.
These tests verify that using both --local and --cloud together
produces an appropriate error message.
"""
def test_status_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["status", "--local", "--cloud"])
# Exit code can be 1 or 2 depending on how typer handles the exception
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_project_list_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["project", "list", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_search_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_read_note_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(cli_app, ["tool", "read-note", "test", "--local", "--cloud"])
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_build_context_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(
cli_app, ["tool", "build-context", "memory://test", "--local", "--cloud"]
)
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
class TestMcpCommandForcesLocal:
"""Tests that the MCP command forces local routing."""
def test_mcp_sets_force_local_env(self, monkeypatch):
"""MCP command should set BASIC_MEMORY_FORCE_LOCAL before server starts."""
# Track what environment variable was set
env_set_value = []
# Mock the MCP server run to capture env state without actually starting server
import basic_memory.cli.commands.mcp as mcp_mod
def mock_run(*args, **kwargs):
env_set_value.append(os.environ.get("BASIC_MEMORY_FORCE_LOCAL"))
# Don't actually start the server
raise SystemExit(0)
# Get the actual mcp_server from the module
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
# Also mock init_mcp_logging to avoid file operations
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
runner.invoke(cli_app, ["mcp"])
# Environment variable should have been set to "true"
assert len(env_set_value) == 1
assert env_set_value[0] == "true"
class TestToolCommandsAcceptFlags:
"""Tests that tool commands accept routing flags without parsing errors."""
@pytest.mark.parametrize(
"command,args",
[
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
)
def test_tool_commands_accept_local_flag(self, command, args, app_config):
"""Tool commands should accept --local flag without parsing error."""
full_args = ["tool", command] + args + ["--local"]
result = runner.invoke(cli_app, full_args)
# Should not fail due to flag parsing (No such option error)
assert "No such option: --local" not in result.output
@pytest.mark.parametrize(
"command,args",
[
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
)
def test_tool_commands_accept_cloud_flag(self, command, args, app_config):
"""Tool commands should accept --cloud flag without parsing error."""
full_args = ["tool", command] + args + ["--cloud"]
result = runner.invoke(cli_app, full_args)
# Should not fail due to flag parsing (No such option error)
assert "No such option: --cloud" not in result.output
class TestProjectCommandsAcceptFlags:
"""Tests that project commands accept routing flags without parsing errors."""
def test_project_list_accepts_local_flag(self, app_config):
"""project list should accept --local flag."""
result = runner.invoke(cli_app, ["project", "list", "--local"])
assert "No such option: --local" not in result.output
def test_project_list_accepts_cloud_flag(self, app_config):
"""project list should accept --cloud flag."""
result = runner.invoke(cli_app, ["project", "list", "--cloud"])
assert "No such option: --cloud" not in result.output
def test_project_info_accepts_local_flag(self, app_config):
"""project info should accept --local flag."""
result = runner.invoke(cli_app, ["project", "info", "--local"])
assert "No such option: --local" not in result.output
def test_project_info_accepts_cloud_flag(self, app_config):
"""project info should accept --cloud flag."""
result = runner.invoke(cli_app, ["project", "info", "--cloud"])
assert "No such option: --cloud" not in result.output
def test_project_default_accepts_local_flag(self, app_config):
"""project default should accept --local flag."""
result = runner.invoke(cli_app, ["project", "default", "test", "--local"])
assert "No such option: --local" not in result.output
def test_project_sync_config_accepts_local_flag(self, app_config):
"""project sync-config should accept --local flag."""
result = runner.invoke(cli_app, ["project", "sync-config", "test", "--local"])
assert "No such option: --local" not in result.output
def test_project_move_accepts_local_flag(self, app_config):
"""project move should accept --local flag."""
result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--local"])
assert "No such option: --local" not in result.output
class TestStatusCommandAcceptsFlags:
"""Tests that status command accepts routing flags."""
def test_status_accepts_local_flag(self, app_config):
"""status should accept --local flag."""
result = runner.invoke(cli_app, ["status", "--local"])
assert "No such option: --local" not in result.output
def test_status_accepts_cloud_flag(self, app_config):
"""status should accept --cloud flag."""
result = runner.invoke(cli_app, ["status", "--cloud"])
assert "No such option: --cloud" not in result.output
@@ -15,7 +15,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Parent Entity",
"folder": "testing",
"directory": "testing",
"content": "# Parent Entity\n\nMain entity for testing underscore relations.",
"tags": "test,parent",
},
@@ -27,7 +27,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Child with Underscore",
"folder": "testing",
"directory": "testing",
"content": """# Child with Underscore
- part_of [[Parent Entity]]
@@ -42,7 +42,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "Child with Hyphen",
"folder": "testing",
"directory": "testing",
"content": """# Child with Hyphen
- part-of [[Parent Entity]]
@@ -124,7 +124,7 @@ async def test_build_context_complex_underscore_paths(mcp_server, app, test_proj
{
"project": test_project.name,
"title": "workflow_manager_agent",
"folder": "specs",
"directory": "specs",
"content": """# Workflow Manager Agent
Specification for the workflow manager agent.
@@ -138,7 +138,7 @@ Specification for the workflow manager agent.
{
"project": test_project.name,
"title": "task_parser",
"folder": "components",
"directory": "components",
"content": """# Task Parser
- part_of [[workflow_manager_agent]]
@@ -15,7 +15,7 @@ async def test_build_context_valid_urls(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "URL Validation Test",
"folder": "testing",
"directory": "testing",
"content": "# URL Validation Test\n\nThis note tests URL validation.",
"tags": "test,validation",
},
@@ -169,7 +169,7 @@ async def test_build_context_pattern_matching_works(mcp_server, app, test_projec
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": content,
},
)
+10 -10
View File
@@ -36,7 +36,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Machine Learning Fundamentals",
"folder": "ai",
"directory": "ai",
"content": (
"# Machine Learning Fundamentals\n\nIntroduction to ML concepts and algorithms."
),
@@ -49,7 +49,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Deep Learning with PyTorch",
"folder": "ai",
"directory": "ai",
"content": (
"# Deep Learning with PyTorch\n\n"
"Building neural networks using PyTorch framework."
@@ -63,7 +63,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Data Visualization Guide",
"folder": "data",
"directory": "data",
"content": (
"# Data Visualization Guide\n\nCreating charts and graphs for data analysis."
),
@@ -127,7 +127,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Python Web Frameworks",
"folder": "dev",
"directory": "dev",
"content": (
"# Python Web Frameworks\n\nComparing Django and Flask for web development."
),
@@ -140,7 +140,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "JavaScript Frameworks",
"folder": "dev",
"directory": "dev",
"content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.",
"tags": "javascript,web,frameworks",
},
@@ -191,7 +191,7 @@ def my_decorator(func):
{
"project": test_project.name,
"title": "Advanced Python Techniques",
"folder": "programming",
"directory": "programming",
"content": note_content,
"tags": "python,advanced,programming",
},
@@ -231,7 +231,7 @@ async def test_chatgpt_fetch_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Test Document",
"folder": "test",
"directory": "test",
"content": "# Test Document\n\nThis is test content for permalink fetching.",
"tags": "test",
},
@@ -301,7 +301,7 @@ async def test_chatgpt_fetch_with_empty_title(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "untitled-note",
"folder": "misc",
"directory": "misc",
"content": "This is content without a markdown header.\n\nJust plain text.",
"tags": "misc",
},
@@ -337,7 +337,7 @@ async def test_chatgpt_search_pagination_default(mcp_server, app, test_project):
{
"project": test_project.name,
"title": f"Test Note {i}",
"folder": "bulk",
"directory": "bulk",
"content": f"# Test Note {i}\n\nThis is test content number {i}.",
"tags": "test,bulk",
},
@@ -419,7 +419,7 @@ async def test_chatgpt_integration_workflow(mcp_server, app, test_project):
{
"project": test_project.name,
"title": doc["title"],
"folder": "architecture",
"directory": "architecture",
"content": doc["content"],
"tags": doc["tags"],
},
@@ -34,7 +34,7 @@ async def test_default_project_mode_enabled_write_note(mcp_server, app, test_pro
"write_note",
{
"title": "Default Mode Test",
"folder": "test",
"directory": "test",
"content": "# Default Mode Test\n\nThis should use the default project automatically.",
"tags": "default,mode,test",
},
@@ -86,7 +86,7 @@ async def test_default_project_mode_explicit_override(
"write_note",
{
"title": "Override Test",
"folder": "test",
"directory": "test",
"content": "# Override Test\n\nThis should go to the explicitly specified project.",
"project": other_project.name, # Explicit override
},
@@ -120,7 +120,7 @@ async def test_default_project_mode_disabled_requires_project(mcp_server, app, t
"write_note",
{
"title": "Should Fail",
"folder": "test",
"directory": "test",
"content": "# Should Fail\n\nThis should fail because no project specified.",
},
)
@@ -173,7 +173,7 @@ async def test_cli_constraint_overrides_default_project_mode(
"write_note",
{
"title": "CLI Constraint Test",
"folder": "test",
"directory": "test",
"content": "# CLI Constraint Test\n\nThis should use CLI constrained project.",
},
)
@@ -210,7 +210,7 @@ async def test_default_project_mode_read_note(mcp_server, app, test_project):
"write_note",
{
"title": "Read Test Note",
"folder": "test",
"directory": "test",
"content": "# Read Test Note\n\nThis note will be read back.",
},
)
@@ -249,7 +249,7 @@ async def test_default_project_mode_edit_note(mcp_server, app, test_project):
"write_note",
{
"title": "Edit Test Note",
"folder": "test",
"directory": "test",
"content": "# Edit Test Note\n\nOriginal content.",
},
)
@@ -325,7 +325,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "CLI Priority Test",
"folder": "test",
"directory": "test",
"content": "# CLI Priority Test",
"project": explicit_project.name, # Should be ignored
},
@@ -343,7 +343,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "Explicit Priority Test",
"folder": "test",
"directory": "test",
"content": "# Explicit Priority Test",
"project": explicit_project.name,
},
@@ -358,7 +358,7 @@ async def test_project_resolution_hierarchy(
"write_note",
{
"title": "Default Priority Test",
"folder": "test",
"directory": "test",
"content": "# Default Priority Test",
# No project specified
},
@@ -0,0 +1,268 @@
"""
Integration tests for delete_note with is_directory=True.
Tests the complete directory delete workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_delete_directory_basic(mcp_server, app, test_project):
"""Test basic directory delete operation."""
async with Client(mcp_server) as client:
# Create multiple notes in a source directory
for i in range(3):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Doc {i + 1}",
"directory": "delete-dir",
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
"tags": "test,delete-dir",
},
)
# Verify notes exist
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"delete-dir/doc-{i + 1}",
},
)
assert f"Content for document {i + 1}" in read_result.content[0].text
# Delete the entire directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "delete-dir",
"is_directory": True,
},
)
# Should return successful delete message with summary
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 3" in delete_text
assert "delete-dir" in delete_text
# Verify all notes are deleted
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"delete-dir/doc-{i + 1}",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_nested(mcp_server, app, test_project):
"""Test deleting a directory with nested subdirectories."""
async with Client(mcp_server) as client:
# Create notes in nested structure
directories = [
"to-delete/2024",
"to-delete/2024/q1",
"to-delete/2024/q2",
]
for dir_path in directories:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"# Note\n\nContent in {dir_path}.",
"tags": "test,nested",
},
)
# Delete the parent directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "to-delete/2024",
"is_directory": True,
},
)
# Should delete all nested files
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 3" in delete_text
# Verify all nested notes are deleted
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "to-delete/2024/q1/note-in-q1",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_empty(mcp_server, app, test_project):
"""Test deleting an empty/non-existent directory returns appropriate message."""
async with Client(mcp_server) as client:
# Try to delete a non-existent/empty directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "nonexistent-dir",
"is_directory": True,
},
)
# Should return message about no files found
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
# Either shows "Directory Deleted Successfully" with 0 files or similar
assert "Total files: 0" in delete_text or "0" in delete_text
@pytest.mark.asyncio
async def test_delete_directory_single_file(mcp_server, app, test_project):
"""Test deleting a directory with only one file."""
async with Client(mcp_server) as client:
# Create single note in directory
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Single Note",
"directory": "single-delete-dir",
"content": "# Single Note\n\nOnly note in this directory.",
"tags": "test,single",
},
)
# Delete directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "single-delete-dir",
"is_directory": True,
},
)
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 1" in delete_text
# Verify note is deleted
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "single-delete-dir/single-note",
},
)
assert "Note Not Found" in read_result.content[0].text
@pytest.mark.asyncio
async def test_delete_directory_search_no_longer_finds(mcp_server, app, test_project):
"""Test that deleted directory contents are no longer searchable."""
async with Client(mcp_server) as client:
# Create searchable note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Searchable Delete Doc",
"directory": "searchable-delete-dir",
"content": "# Searchable Delete Doc\n\nUnique fusionreactor quantum content.",
"tags": "search,test",
},
)
# Verify searchable before delete
search_before = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "fusionreactor quantum",
},
)
assert "Searchable Delete Doc" in search_before.content[0].text
# Delete directory
await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "searchable-delete-dir",
"is_directory": True,
},
)
# Verify no longer searchable after delete
search_after = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "fusionreactor quantum",
},
)
search_text = search_after.content[0].text
# Should not find the deleted note
assert "Searchable Delete Doc" not in search_text or "No results" in search_text
@pytest.mark.asyncio
async def test_delete_directory_many_files(mcp_server, app, test_project):
"""Test deleting a directory with more than 10 files shows truncated list."""
async with Client(mcp_server) as client:
# Create 12 notes in the directory
for i in range(12):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"DeleteManyDoc {i + 1}",
"directory": "delete-many-dir",
"content": f"# Delete Many Doc {i + 1}\n\nContent {i + 1}.",
"tags": "test,many",
},
)
# Delete the entire directory
delete_result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "delete-many-dir",
"is_directory": True,
},
)
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text
assert "Directory Deleted Successfully" in delete_text
assert "Total files: 12" in delete_text
# Should show truncation message for >10 files
assert "... and 2 more" in delete_text
+8 -8
View File
@@ -19,7 +19,7 @@ async def test_delete_note_by_title(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Note to Delete",
"folder": "test",
"directory": "test",
"content": "# Note to Delete\n\nThis note will be deleted.",
"tags": "test,delete",
},
@@ -77,7 +77,7 @@ async def test_delete_note_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Delete Test",
"folder": "tests",
"directory": "tests",
"content": "# Permalink Delete Test\n\nTesting deletion by permalink.",
"tags": "test,permalink",
},
@@ -140,7 +140,7 @@ The system handles multiple projects and users."""
{
"project": test_project.name,
"title": "Project Management System",
"folder": "projects",
"directory": "projects",
"content": complex_content,
"tags": "project,management,system",
},
@@ -208,7 +208,7 @@ async def test_delete_note_special_characters_in_title(mcp_server, app, test_pro
{
"project": test_project.name,
"title": title,
"folder": "special",
"directory": "special",
"content": f"# {title}\n\nContent for {title}",
"tags": "special,characters",
},
@@ -275,7 +275,7 @@ async def test_delete_note_by_file_path(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "File Path Delete",
"folder": "docs",
"directory": "docs",
"content": "# File Path Delete\n\nTesting deletion by file path.",
"tags": "test,filepath",
},
@@ -320,7 +320,7 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "CamelCase Note Title",
"folder": "test",
"directory": "test",
"content": "# CamelCase Note Title\n\nTesting case sensitivity.",
"tags": "test,case",
},
@@ -359,7 +359,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project)
{
"project": test_project.name,
"title": title,
"folder": "batch",
"directory": "batch",
"content": f"# {title}\n\nContent for {title}",
"tags": "batch,test",
},
@@ -421,7 +421,7 @@ This note contains various Unicode characters:
{
"project": test_project.name,
"title": "Unicode Test Note",
"folder": "unicode",
"directory": "unicode",
"content": unicode_content,
"tags": "unicode,test,emoji",
},
+11 -11
View File
@@ -19,7 +19,7 @@ async def test_edit_note_append_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Append Test Note",
"folder": "test",
"directory": "test",
"content": "# Append Test Note\n\nOriginal content here.",
"tags": "test,append",
},
@@ -69,7 +69,7 @@ async def test_edit_note_prepend_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Prepend Test Note",
"folder": "test",
"directory": "test",
"content": "# Prepend Test Note\n\nExisting content.",
"tags": "test,prepend",
},
@@ -122,7 +122,7 @@ async def test_edit_note_find_replace_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Find Replace Test",
"folder": "test",
"directory": "test",
"content": """# Find Replace Test
This is version v1.0.0 of the system.
@@ -182,7 +182,7 @@ async def test_edit_note_replace_section_operation(mcp_server, app, test_project
{
"project": test_project.name,
"title": "Section Replace Test",
"folder": "test",
"directory": "test",
"content": """# Section Replace Test
## Overview
@@ -268,7 +268,7 @@ Current endpoints include user management."""
{
"project": test_project.name,
"title": "API Documentation",
"folder": "docs",
"directory": "docs",
"content": complex_content,
"tags": "api,docs",
},
@@ -356,7 +356,7 @@ async def test_edit_note_error_handling_text_not_found(mcp_server, app, test_pro
{
"project": test_project.name,
"title": "Error Test Note",
"folder": "test",
"directory": "test",
"content": "# Error Test Note\n\nThis note has specific content.",
"tags": "test,error",
},
@@ -394,7 +394,7 @@ async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app,
{
"project": test_project.name,
"title": "Count Test Note",
"folder": "test",
"directory": "test",
"content": """# Count Test Note
The word "test" appears here.
@@ -437,7 +437,7 @@ async def test_edit_note_invalid_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Invalid Op Test",
"folder": "test",
"directory": "test",
"content": "# Invalid Op Test\n\nSome content.",
"tags": "test",
},
@@ -472,7 +472,7 @@ async def test_edit_note_missing_required_parameters(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Param Test Note",
"folder": "test",
"directory": "test",
"content": "# Param Test Note\n\nContent here.",
"tags": "test",
},
@@ -507,7 +507,7 @@ async def test_edit_note_special_characters_in_content(mcp_server, app, test_pro
{
"project": test_project.name,
"title": "Special Chars Test",
"folder": "test",
"directory": "test",
"content": "# Special Chars Test\n\nBasic content here.",
"tags": "test,unicode",
},
@@ -582,7 +582,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Identifier Test Note",
"folder": "docs",
"directory": "docs",
"content": "# Identifier Test Note\n\nOriginal content.",
"tags": "test,identifier",
},
+23 -23
View File
@@ -19,7 +19,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Root Note",
"folder": "", # Root folder
"directory": "", # Root folder
"content": "# Root Note\n\nThis is in the root directory.",
"tags": "test,root",
},
@@ -30,7 +30,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Planning",
"folder": "projects",
"directory": "projects",
"content": "# Project Planning\n\nPlanning document for projects.",
"tags": "planning,project",
},
@@ -41,7 +41,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes\n\nNotes from the meeting.",
"tags": "meeting,notes",
},
@@ -83,7 +83,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Task List",
"folder": "work",
"directory": "work",
"content": "# Task List\n\nWork tasks for today.",
"tags": "work,tasks",
},
@@ -94,7 +94,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Alpha",
"folder": "work/projects",
"directory": "work/projects",
"content": "# Project Alpha\n\nAlpha project documentation.",
"tags": "project,alpha",
},
@@ -105,7 +105,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Daily Standup",
"folder": "work/meetings",
"directory": "work/meetings",
"content": "# Daily Standup\n\nStandup meeting notes.",
"tags": "meeting,standup",
},
@@ -143,7 +143,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Deep Note",
"folder": "research/ml/algorithms/neural-networks",
"directory": "research/ml/algorithms/neural-networks",
"content": "# Deep Note\n\nDeep learning research.",
"tags": "research,ml,deep",
},
@@ -154,7 +154,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "ML Overview",
"folder": "research/ml",
"directory": "research/ml",
"content": "# ML Overview\n\nMachine learning overview.",
"tags": "research,ml,overview",
},
@@ -165,7 +165,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Research Index",
"folder": "research",
"directory": "research",
"content": "# Research Index\n\nIndex of research topics.",
"tags": "research,index",
},
@@ -203,7 +203,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting 2025-01-15",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting 2025-01-15\n\nMonday meeting notes.",
"tags": "meeting,january",
},
@@ -214,7 +214,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting 2025-01-22",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting 2025-01-22\n\nMonday meeting notes.",
"tags": "meeting,january",
},
@@ -225,7 +225,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Status",
"folder": "meetings",
"directory": "meetings",
"content": "# Project Status\n\nProject status update.",
"tags": "meeting,project",
},
@@ -285,7 +285,7 @@ async def test_list_directory_glob_no_matches(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Document One",
"folder": "docs",
"directory": "docs",
"content": "# Document One\n\nFirst document.",
"tags": "doc",
},
@@ -320,7 +320,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Simple Note",
"folder": "mixed",
"directory": "mixed",
"content": "# Simple Note\n\nA simple note.",
"tags": "simple",
},
@@ -331,7 +331,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Complex Document with Long Title",
"folder": "mixed",
"directory": "mixed",
"content": "# Complex Document with Long Title\n\nA more complex document.",
"tags": "complex,long",
},
@@ -369,7 +369,7 @@ async def test_list_directory_default_parameters(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Default Test",
"folder": "default-test",
"directory": "default-test",
"content": "# Default Test\n\nTesting default parameters.",
"tags": "default",
},
@@ -401,7 +401,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Level 5 Note",
"folder": "level1/level2/level3/level4/level5",
"directory": "level1/level2/level3/level4/level5",
"content": "# Level 5 Note\n\nVery deep note.",
"tags": "deep,level5",
},
@@ -412,7 +412,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Level 3 Note",
"folder": "level1/level2/level3",
"directory": "level1/level2/level3",
"content": "# Level 3 Note\n\nMid-level note.",
"tags": "medium,level3",
},
@@ -449,7 +449,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Project Alpha Plan",
"folder": "patterns",
"directory": "patterns",
"content": "# Project Alpha Plan\n\nAlpha planning.",
"tags": "project,alpha",
},
@@ -460,7 +460,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Project Beta Plan",
"folder": "patterns",
"directory": "patterns",
"content": "# Project Beta Plan\n\nBeta planning.",
"tags": "project,beta",
},
@@ -471,7 +471,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
{
"project": test_project.name,
"title": "Meeting Minutes",
"folder": "patterns",
"directory": "patterns",
"content": "# Meeting Minutes\n\nMeeting notes.",
"tags": "meeting",
},
@@ -508,7 +508,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Artifact One",
"folder": "artifacts",
"directory": "artifacts",
"content": "# Artifact One\n\nFirst artifact document.",
"tags": "artifact,test",
},
@@ -519,7 +519,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Artifact Two",
"folder": "artifacts",
"directory": "artifacts",
"content": "# Artifact Two\n\nSecond artifact document.",
"tags": "artifact,test",
},
@@ -0,0 +1,343 @@
"""
Integration tests for move_note with is_directory=True.
Tests the complete directory move workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_move_directory_basic(mcp_server, app, test_project):
"""Test basic directory move operation."""
async with Client(mcp_server) as client:
# Create multiple notes in a source directory
for i in range(3):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Doc {i + 1}",
"directory": "source-dir",
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
"tags": "test,move-dir",
},
)
# Move the entire directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "source-dir",
"destination_path": "dest-dir",
"is_directory": True,
},
)
# Should return successful move message with summary
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 3" in move_text
assert "source-dir" in move_text
assert "dest-dir" in move_text
# Verify all notes can be read from new locations
for i in range(3):
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"dest-dir/doc-{i + 1}",
},
)
content = read_result.content[0].text
assert f"Content for document {i + 1}" in content
# Verify original locations no longer exist
for i in range(3):
read_original = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": f"source-dir/doc-{i + 1}",
},
)
assert "Note Not Found" in read_original.content[0].text
@pytest.mark.asyncio
async def test_move_directory_nested(mcp_server, app, test_project):
"""Test moving a directory with nested subdirectories."""
async with Client(mcp_server) as client:
# Create notes in nested structure
directories = [
"projects/2024",
"projects/2024/q1",
"projects/2024/q2",
]
for dir_path in directories:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"# Note\n\nContent in {dir_path}.",
"tags": "test,nested",
},
)
# Move the parent directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "projects/2024",
"destination_path": "archive/2024",
"is_directory": True,
},
)
# Should move all nested files
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 3" in move_text
# Verify nested structure is preserved
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "archive/2024/q1/note-in-q1",
},
)
assert "Content in projects/2024/q1" in read_result.content[0].text
read_result2 = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "archive/2024/q2/note-in-q2",
},
)
assert "Content in projects/2024/q2" in read_result2.content[0].text
@pytest.mark.asyncio
async def test_move_directory_empty(mcp_server, app, test_project):
"""Test moving an empty directory returns appropriate message."""
async with Client(mcp_server) as client:
# Try to move a non-existent/empty directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "nonexistent-dir",
"destination_path": "dest-dir",
"is_directory": True,
},
)
# Should return message about no files found
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "No files found" in move_text or "0" in move_text
@pytest.mark.asyncio
async def test_move_directory_preserves_content(mcp_server, app, test_project):
"""Test that directory move preserves all note content including observations and relations."""
async with Client(mcp_server) as client:
# Create note with complex content
complex_content = """# Complex Note
## Observations
- [feature] Important feature observation
- [tech] Technical detail here
## Relations
- relates_to [[Other Note]]
- implements [[Specification]]
## Content
Detailed content that must be preserved."""
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Complex Note",
"directory": "source-complex",
"content": complex_content,
"tags": "test,complex",
},
)
# Move the directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "source-complex",
"destination_path": "dest-complex",
"is_directory": True,
},
)
assert "Directory Moved Successfully" in move_result.content[0].text
# Verify content preservation
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "dest-complex/complex-note",
},
)
content = read_result.content[0].text
assert "Important feature observation" in content
assert "Technical detail here" in content
assert "relates_to [[Other Note]]" in content
assert "Detailed content that must be preserved" in content
@pytest.mark.asyncio
async def test_move_directory_search_still_works(mcp_server, app, test_project):
"""Test that moved directory contents remain searchable."""
async with Client(mcp_server) as client:
# Create searchable note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Searchable Doc",
"directory": "searchable-dir",
"content": "# Searchable Doc\n\nUnique quantum entanglement research content.",
"tags": "search,test",
},
)
# Verify searchable before move
search_before = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "quantum entanglement",
},
)
assert "Searchable Doc" in search_before.content[0].text
# Move directory
await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "searchable-dir",
"destination_path": "moved-searchable",
"is_directory": True,
},
)
# Verify still searchable after move
search_after = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "quantum entanglement",
},
)
search_text = search_after.content[0].text
assert "quantum entanglement" in search_text
assert "moved-searchable" in search_text or "searchable-doc" in search_text
@pytest.mark.asyncio
async def test_move_directory_single_file(mcp_server, app, test_project):
"""Test moving a directory with only one file."""
async with Client(mcp_server) as client:
# Create single note in directory
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Single Note",
"directory": "single-dir",
"content": "# Single Note\n\nOnly note in this directory.",
"tags": "test,single",
},
)
# Move directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "single-dir",
"destination_path": "moved-single",
"is_directory": True,
},
)
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 1" in move_text
# Verify note at new location
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "moved-single/single-note",
},
)
assert "Only note in this directory" in read_result.content[0].text
@pytest.mark.asyncio
async def test_move_directory_many_files(mcp_server, app, test_project):
"""Test moving a directory with more than 10 files shows truncated list."""
async with Client(mcp_server) as client:
# Create 12 notes in the directory
for i in range(12):
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": f"ManyDoc {i + 1}",
"directory": "many-files-dir",
"content": f"# Many Doc {i + 1}\n\nContent {i + 1}.",
"tags": "test,many",
},
)
# Move the directory
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "many-files-dir",
"destination_path": "moved-many",
"is_directory": True,
},
)
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert "Directory Moved Successfully" in move_text
assert "Total files: 12" in move_text
# Should show truncation message for >10 files
assert "... and 2 more" in move_text
+14 -14
View File
@@ -19,7 +19,7 @@ async def test_move_note_basic_operation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Move Test Note",
"folder": "source",
"directory": "source",
"content": "# Move Test Note\n\nThis note will be moved to a new location.",
"tags": "test,move",
},
@@ -79,7 +79,7 @@ async def test_move_note_using_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Move Test",
"folder": "test",
"directory": "test",
"content": "# Permalink Move Test\n\nMoving by permalink.",
"tags": "test,permalink",
},
@@ -142,7 +142,7 @@ This note demonstrates moving complex content."""
{
"project": test_project.name,
"title": "Complex Note",
"folder": "complex",
"directory": "complex",
"content": complex_content,
"tags": "test,complex,move",
},
@@ -193,7 +193,7 @@ async def test_move_note_to_nested_directory(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Nested Move Test",
"folder": "root",
"directory": "root",
"content": "# Nested Move Test\n\nThis will be moved deep.",
"tags": "test,nested",
},
@@ -239,7 +239,7 @@ async def test_move_note_with_special_characters(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Special (Chars) & Symbols",
"folder": "special",
"directory": "special",
"content": "# Special (Chars) & Symbols\n\nTesting special characters in move.",
"tags": "test,special",
},
@@ -306,7 +306,7 @@ async def test_move_note_error_handling_invalid_destination(mcp_server, app, tes
{
"project": test_project.name,
"title": "Invalid Dest Test",
"folder": "test",
"directory": "test",
"content": "# Invalid Dest Test\n\nThis move should fail.",
"tags": "test,error",
},
@@ -340,7 +340,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
{
"project": test_project.name,
"title": "Source Note",
"folder": "source",
"directory": "source",
"content": "# Source Note\n\nThis is the source.",
"tags": "test,source",
},
@@ -352,7 +352,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
{
"project": test_project.name,
"title": "Existing Note",
"folder": "destination",
"directory": "destination",
"content": "# Existing Note\n\nThis already exists.",
"tags": "test,existing",
},
@@ -386,7 +386,7 @@ async def test_move_note_preserves_search_functionality(mcp_server, app, test_pr
{
"project": test_project.name,
"title": "Searchable Note",
"folder": "original",
"directory": "original",
"content": """# Searchable Note
This note contains unique search terms:
@@ -467,7 +467,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Title ID Note",
"folder": "test",
"directory": "test",
"content": "# Title ID Note\n\nMove by title.",
"tags": "test,identifier",
},
@@ -478,7 +478,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Permalink ID Note",
"folder": "test",
"directory": "test",
"content": "# Permalink ID Note\n\nMove by permalink.",
"tags": "test,identifier",
},
@@ -489,7 +489,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
{
"project": test_project.name,
"title": "Folder Title Note",
"folder": "test",
"directory": "test",
"content": "# Folder Title Note\n\nMove by folder/title.",
"tags": "test,identifier",
},
@@ -569,7 +569,7 @@ async def test_move_note_cross_project_detection(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Cross Project Test Note",
"folder": "source",
"directory": "source",
"content": "# Cross Project Test Note\n\nThis note is in the default project.",
"tags": "test,cross-project",
},
@@ -605,7 +605,7 @@ async def test_move_note_normal_moves_still_work(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Normal Move Note",
"folder": "source",
"directory": "source",
"content": "# Normal Move Note\n\nThis should move normally.",
"tags": "test,normal-move",
},
@@ -276,7 +276,7 @@ async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_pat
{
"project": project_name,
"title": "Lifecycle Test Note",
"folder": "test",
"directory": "test",
"content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing",
"tags": "lifecycle,test",
},
@@ -390,7 +390,7 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project,
{
"project": test_input, # Use different case
"title": f"Case Test {test_input}",
"folder": "case-test",
"directory": "case-test",
"content": f"# Case Test\n\nTesting with {test_input}",
},
)
@@ -427,7 +427,7 @@ async def test_case_insensitive_project_operations(mcp_server, app, test_project
{
"project": project_name,
"title": "Case Test Note",
"folder": "case-test",
"directory": "case-test",
"content": "# Case Test Note\n\nTesting case-insensitive operations.\n\n- [test] Case insensitive switch\n- relates_to [[Another Note]]",
"tags": "case,test",
},
@@ -478,7 +478,7 @@ async def test_case_insensitive_error_handling(mcp_server, app, test_project):
{
"project": test_case,
"title": "Test Note",
"folder": "test",
"directory": "test",
"content": "# Test\n\nTest content.",
},
)
@@ -524,7 +524,7 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project,
{
"project": project_name, # Use exact project name
"title": f"Test Note {project_name}",
"folder": "test",
"directory": "test",
"content": f"# Test\n\nTesting {project_name}",
},
)
@@ -41,7 +41,7 @@ async def test_project_state_sync_after_default_change(
{
"project": "minerva",
"title": "Test Consistency Note",
"folder": "test",
"directory": "test",
"content": "# Test Note\n\nThis note tests project state consistency.\n\n- [test] Project state sync working",
"tags": "test,consistency",
},
@@ -29,7 +29,7 @@ async def test_read_content_markdown_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Content Test",
"folder": "test",
"directory": "test",
"content": "# Content Test\n\nThis is test content with **markdown**.",
"tags": "test,content",
},
@@ -72,7 +72,7 @@ async def test_read_content_by_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Permalink Test",
"folder": "docs",
"directory": "docs",
"content": "# Permalink Test\n\nTesting permalink-based content reading.",
},
)
@@ -105,7 +105,7 @@ async def test_read_content_memory_url(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Memory URL Test",
"folder": "test",
"directory": "test",
"content": "# Memory URL Test\n\nTesting memory:// URL handling.",
"tags": "memory,url",
},
@@ -143,7 +143,7 @@ async def test_read_content_unicode_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Unicode Content Test",
"folder": "test",
"directory": "test",
"content": unicode_content,
"tags": "unicode,emoji",
},
@@ -204,7 +204,7 @@ Regular markdown content continues here."""
{
"project": test_project.name,
"title": "Complex Note",
"folder": "docs",
"directory": "docs",
"content": complex_content,
"tags": "complex,frontmatter",
},
@@ -263,7 +263,7 @@ async def test_read_content_empty_file(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Empty Test",
"folder": "test",
"directory": "test",
"content": "", # Empty content
},
)
@@ -316,7 +316,7 @@ eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident.
{
"project": test_project.name,
"title": "Large Content Note",
"folder": "test",
"directory": "test",
"content": large_content,
"tags": "large,content,test",
},
@@ -362,7 +362,7 @@ async def test_read_content_special_characters_in_filename(mcp_server, app, test
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": f"# {title}\n\nContent for {title}",
},
)
+2 -2
View File
@@ -19,7 +19,7 @@ async def test_read_note_after_write(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Test Note",
"folder": "test",
"directory": "test",
"content": "# Test Note\n\nThis is test content.",
"tags": "test,integration",
},
@@ -64,7 +64,7 @@ async def test_read_note_underscored_folder_by_permalink(mcp_server, app, test_p
{
"project": test_project.name,
"title": "Example Note",
"folder": "_archive/articles",
"directory": "_archive/articles",
"content": "# Example Note\n\nThis is a test note in an underscored folder.",
"tags": "test,archive",
},
+20 -20
View File
@@ -20,7 +20,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Programming Guide",
"folder": "docs",
"directory": "docs",
"content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.",
"tags": "python,programming",
},
@@ -31,7 +31,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Flask Web Development",
"folder": "docs",
"directory": "docs",
"content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.",
"tags": "python,flask,web",
},
@@ -42,7 +42,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "JavaScript Basics",
"folder": "docs",
"directory": "docs",
"content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.",
"tags": "javascript,programming",
},
@@ -78,7 +78,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Flask Tutorial",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.",
"tags": "python,flask,tutorial",
},
@@ -89,7 +89,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Django Guide",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.",
"tags": "python,django,web",
},
@@ -100,7 +100,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "React JavaScript",
"folder": "tutorials",
"directory": "tutorials",
"content": "# React JavaScript\n\nBuilding frontend applications with React.",
"tags": "javascript,react,frontend",
},
@@ -159,7 +159,7 @@ async def test_search_title_only(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Database Design",
"folder": "docs",
"directory": "docs",
"content": "# Database Design\n\nThis covers SQL and database concepts.",
"tags": "database,sql",
},
@@ -170,7 +170,7 @@ async def test_search_title_only(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Web Development",
"folder": "docs",
"directory": "docs",
"content": "# Web Development\n\nDatabase integration in web applications.",
"tags": "web,development",
},
@@ -202,7 +202,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "API Documentation",
"folder": "api",
"directory": "api",
"content": "# API Documentation\n\nComplete API reference guide.",
"tags": "api,docs",
},
@@ -213,7 +213,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "API Testing",
"folder": "testing",
"directory": "testing",
"content": "# API Testing\n\nHow to test REST APIs.",
"tags": "api,testing",
},
@@ -245,7 +245,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes January",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes January\n\nJanuary team meeting notes.",
"tags": "meetings,january",
},
@@ -256,7 +256,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Meeting Notes February",
"folder": "meetings",
"directory": "meetings",
"content": "# Meeting Notes February\n\nFebruary team meeting notes.",
"tags": "meetings,february",
},
@@ -267,7 +267,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Project Notes",
"folder": "projects",
"directory": "projects",
"content": "# Project Notes\n\nGeneral project documentation.",
"tags": "projects,notes",
},
@@ -314,7 +314,7 @@ Regular content about development practices."""
{
"project": test_project.name,
"title": "Development Process",
"folder": "processes",
"directory": "processes",
"content": content_with_observations,
"tags": "development,process",
},
@@ -347,7 +347,7 @@ async def test_search_pagination(mcp_server, app, test_project):
{
"project": test_project.name,
"title": f"Test Note {i + 1:02d}",
"folder": "test",
"directory": "test",
"content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.",
"tags": "test,pagination",
},
@@ -395,7 +395,7 @@ async def test_search_no_results(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Sample Note",
"folder": "test",
"directory": "test",
"content": "# Sample Note\n\nThis is a sample note for testing.",
"tags": "sample,test",
},
@@ -425,7 +425,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Web Development",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.",
"tags": "python,web,development",
},
@@ -436,7 +436,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Python Data Science",
"folder": "tutorials",
"directory": "tutorials",
"content": "# Python Data Science\n\nData analysis and machine learning with Python.",
"tags": "python,data,science",
},
@@ -447,7 +447,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "JavaScript Web Development",
"folder": "tutorials",
"directory": "tutorials",
"content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.",
"tags": "javascript,web,development",
},
@@ -479,7 +479,7 @@ async def test_search_case_insensitive(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Machine Learning Guide",
"folder": "guides",
"directory": "guides",
"content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.",
"tags": "ML,AI",
},
@@ -26,7 +26,7 @@ async def test_project_constraint_override_content_tools(mcp_server, app, test_p
{
"project": "some-other-project", # Should be ignored
"title": "Constraint Test Note",
"folder": "test",
"directory": "test",
"content": "# Constraint Test\n\nThis should go to the constrained project.",
"tags": "constraint,test",
},
@@ -62,7 +62,7 @@ async def test_project_constraint_read_note_override(mcp_server, app, test_proje
{
"project": test_project.name,
"title": "Read Test Note",
"folder": "test",
"directory": "test",
"content": "# Read Test\n\nContent for reading test.",
},
)
@@ -103,7 +103,7 @@ async def test_project_constraint_search_notes_override(mcp_server, app, test_pr
{
"project": test_project.name,
"title": "Searchable Note",
"folder": "test",
"directory": "test",
"content": "# Searchable\n\nThis content has unique searchable terms.",
},
)
@@ -221,7 +221,7 @@ async def test_normal_mode_without_constraint(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Normal Mode Note",
"folder": "test",
"directory": "test",
"content": "# Normal Mode\n\nThis should work normally.",
},
)
@@ -254,7 +254,7 @@ async def test_constraint_with_multiple_content_tools(mcp_server, app, test_proj
{
"project": "wrong-project",
"title": "Multi Tool Test",
"folder": "test",
"directory": "test",
"content": "# Multi Tool Test\n\n- [note] Testing multiple tools",
},
)
@@ -324,7 +324,7 @@ async def test_constraint_with_invalid_project_override(mcp_server, app, test_pr
{
"project": invalid_project,
"title": f"Test Invalid {i} {invalid_project[:5]}",
"folder": "test",
"directory": "test",
"content": f"Testing with invalid project: {invalid_project}",
},
)
+13 -13
View File
@@ -25,7 +25,7 @@ async def test_write_note_basic_creation(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Simple Note",
"folder": "basic",
"directory": "basic",
"content": "# Simple Note\n\nThis is a simple note for testing.",
"tags": "simple,test",
},
@@ -54,7 +54,7 @@ async def test_write_note_no_tags(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "No Tags Note",
"folder": "test",
"directory": "test",
"content": "Just some plain text without tags.",
},
)
@@ -80,7 +80,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Update Test",
"folder": "test",
"directory": "test",
"content": "# Update Test\n\nOriginal content.",
"tags": "original",
},
@@ -94,7 +94,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Update Test",
"folder": "test",
"directory": "test",
"content": "# Update Test\n\nUpdated content with changes.",
"tags": "updated,modified",
},
@@ -123,7 +123,7 @@ async def test_write_note_tag_array(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Array Tags Test",
"folder": "test",
"directory": "test",
"content": "Testing tag array handling",
"tags": ["python", "testing", "integration", "mcp"],
},
@@ -164,7 +164,7 @@ async def test_write_note_custom_permalink(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Custom Permalink Note",
"folder": "notes",
"directory": "notes",
"content": content_with_custom_permalink,
},
)
@@ -192,7 +192,7 @@ async def test_write_note_unicode_content(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Unicode Test 🌟",
"folder": "test",
"directory": "test",
"content": unicode_content,
"tags": "unicode,emoji,测试",
},
@@ -243,7 +243,7 @@ async def test_write_note_complex_content_with_observations_relations(
{
"project": test_project.name,
"title": "Complex Knowledge Note",
"folder": "knowledge",
"directory": "knowledge",
"content": complex_content,
"tags": "complex,knowledge,relations",
},
@@ -296,7 +296,7 @@ async def test_write_note_preserve_frontmatter(mcp_server, app, test_project):
{
"project": test_project.name,
"title": "Frontmatter Note",
"folder": "test",
"directory": "test",
"content": content_with_frontmatter,
"tags": "frontmatter,preservation",
},
@@ -326,7 +326,7 @@ async def test_write_note_kebab_filenames_basic(mcp_server, app, test_project, a
{
"project": test_project.name,
"title": "My Note: With/Invalid|Chars?",
"folder": "my-folder",
"directory": "my-folder",
"content": "Testing kebab-case and invalid characters.",
"tags": "kebab,invalid,filename",
},
@@ -355,7 +355,7 @@ async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, app, test_p
{
"project": test_project.name,
"title": 'Crazy<>:"|?*Note/Name',
"folder": "my-folder",
"directory": "my-folder",
"content": "Should be fully kebab-case and safe.",
"tags": "crazy,filename,test",
},
@@ -404,7 +404,7 @@ async def test_write_note_file_path_os_path_join(mcp_server, app, test_project,
{
"project": test_project.name,
"title": title,
"folder": folder,
"directory": folder,
"content": "Testing os.path.join logic.",
"tags": "integration,ospath",
},
@@ -462,7 +462,7 @@ async def test_write_note_project_path_validation(mcp_server, app, test_project)
{
"project": test_project.name,
"title": "Validation Test",
"folder": "documents",
"directory": "documents",
"content": "Testing path validation",
"tags": "test",
},
@@ -61,7 +61,7 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_co
# Create entity via API
entity_data = EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Test content",
)
+11 -11
View File
@@ -153,7 +153,7 @@ async def test_import_chatgpt(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -186,7 +186,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, projec
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request - this should return an error
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -207,7 +207,7 @@ async def test_import_claude_conversations(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request
response = await client.post(
@@ -242,7 +242,7 @@ async def test_import_claude_conversations_invalid_file(client: AsyncClient, tmp
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request - this should return an error
response = await client.post(
@@ -265,7 +265,7 @@ async def test_import_claude_projects(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("projects.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request
response = await client.post(
@@ -305,7 +305,7 @@ async def test_import_claude_projects_invalid_file(client: AsyncClient, tmp_path
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request - this should return an error
response = await client.post(
@@ -331,7 +331,7 @@ async def test_import_memory_json(
# Create a multipart form with the file
with open(json_file, "rb") as f:
files = {"file": ("memory.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request
response = await client.post(f"{project_url}/import/memory-json", files=files, data=data)
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, pr
async def test_import_missing_file(client: AsyncClient, tmp_path, project_url):
"""Test importing with missing file."""
# Send a request without a file
response = await client.post(f"{project_url}/import/chatgpt", data={"folder": "test_folder"})
response = await client.post(f"{project_url}/import/chatgpt", data={"directory": "test_folder"})
# Check that the request was rejected
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
@@ -426,7 +426,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, project_url):
# Create multipart form with empty file
with open(file_path, "rb") as f:
files = {"file": ("empty.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
@@ -446,8 +446,8 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, project_url)
# Test all import endpoints
endpoints = [
(f"{project_url}/import/chatgpt", {"folder": "test"}),
(f"{project_url}/import/claude/conversations", {"folder": "test"}),
(f"{project_url}/import/chatgpt", {"directory": "test"}),
(f"{project_url}/import/claude/conversations", {"directory": "test"}),
(f"{project_url}/import/claude/projects", {"base_folder": "test"}),
(f"{project_url}/import/memory-json", {"destination_folder": "test"}),
]
+161 -44
View File
@@ -9,6 +9,7 @@ from basic_memory.schemas import (
Entity,
EntityResponse,
)
from basic_memory.schemas.response import DirectoryMoveResult
from basic_memory.schemas.search import SearchItemType, SearchResponse
from basic_memory.utils import normalize_newlines
@@ -19,7 +20,7 @@ async def test_create_entity(client: AsyncClient, file_service, project_url):
data = {
"title": "TestEntity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "TestContent",
"project": "Test Project Context",
@@ -53,7 +54,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
data = {
"title": "TestEntity",
"folder": "test",
"directory": "test",
"content": """
# TestContent
@@ -98,7 +99,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
# Create first entity with unresolved relation
entity1_data = {
"title": "EntityOne",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "This entity references [[EntityTwo]]",
}
@@ -116,7 +117,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
# Create the referenced entity
entity2_data = {
"title": "EntityTwo",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": "This is the referenced entity",
}
@@ -141,7 +142,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
async def test_get_entity_by_permalink(client: AsyncClient, project_url):
"""Should retrieve an entity by path ID."""
# First create an entity
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=data)
assert response.status_code == 200
data = response.json()
@@ -163,7 +164,7 @@ async def test_get_entity_by_permalink(client: AsyncClient, project_url):
async def test_get_entity_by_file_path(client: AsyncClient, project_url):
"""Should retrieve an entity by path ID."""
# First create an entity
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=data)
assert response.status_code == 200
data = response.json()
@@ -187,11 +188,11 @@ async def test_get_entities(client: AsyncClient, project_url):
# Create a few entities with different names
await client.post(
f"{project_url}/knowledge/entities",
json={"title": "AlphaTest", "folder": "", "entity_type": "test"},
json={"title": "AlphaTest", "directory": "", "entity_type": "test"},
)
await client.post(
f"{project_url}/knowledge/entities",
json={"title": "BetaTest", "folder": "", "entity_type": "test"},
json={"title": "BetaTest", "directory": "", "entity_type": "test"},
)
# Open nodes by path IDs
@@ -241,7 +242,7 @@ async def test_delete_entity(client: AsyncClient, project_url):
async def test_delete_single_entity(client: AsyncClient, project_url):
"""Test DELETE /knowledge/entities with path ID."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
await client.post(f"{project_url}/knowledge/entities", json=entity_data)
# Test deletion
@@ -259,7 +260,7 @@ async def test_delete_single_entity(client: AsyncClient, project_url):
async def test_delete_single_entity_by_title(client: AsyncClient, project_url):
"""Test DELETE /knowledge/entities with file path."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
response = await client.post(f"{project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
data = response.json()
@@ -328,7 +329,7 @@ async def test_entity_indexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SearchTest",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["Unique searchable observation"],
},
@@ -356,7 +357,7 @@ async def test_entity_delete_indexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "DeleteTest",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["Searchable observation that should be removed"],
},
@@ -394,7 +395,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"content": "Initial summary",
"entity_metadata": {"status": "draft"},
@@ -403,7 +404,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.entity_metadata["status"] = "final"
entity.content = "Updated summary"
@@ -429,12 +430,12 @@ async def test_update_entity_content(client: AsyncClient, project_url):
# Create a note entity
response = await client.post(
f"{project_url}/knowledge/entities",
json={"title": "test-note", "folder": "", "entity_type": "note", "summary": "Test note"},
json={"title": "test-note", "directory": "", "entity_type": "note", "summary": "Test note"},
)
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity = Entity(**note, directory="")
entity.content = "# Updated Note\n\nNew content."
response = await client.put(
@@ -458,7 +459,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
# Create a note
note_data = {
"title": "test-note",
"folder": "",
"directory": "",
"entity_type": "note",
"summary": "Test note",
"content": "# Test Note\n\nInitial content.",
@@ -467,7 +468,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity = Entity(**note, directory="")
entity.entity_type = "test"
response = await client.put(
@@ -491,7 +492,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
# Create entity
data = {
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"entity_metadata": {"status": "draft"},
}
@@ -499,7 +500,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.entity_metadata["status"] = "final"
entity.entity_metadata["reviewed"] = True
@@ -521,7 +522,7 @@ async def test_update_entity_not_found_does_create(client: AsyncClient, project_
data = {
"title": "nonexistent",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
@@ -538,7 +539,7 @@ async def test_update_entity_incorrect_permalink(client: AsyncClient, project_ur
data = {
"title": "Test Entity",
"folder": "",
"directory": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
@@ -555,7 +556,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
# Create entity
data = {
"title": "test",
"folder": "",
"directory": "",
"entity_type": "test",
"content": "Initial searchable content",
}
@@ -563,7 +564,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity = Entity(**entity_response, directory="")
entity.content = "Updated with unique sphinx marker"
response = await client.put(
@@ -592,7 +593,7 @@ async def test_edit_entity_append(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -627,7 +628,7 @@ async def test_edit_entity_prepend(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -671,7 +672,7 @@ async def test_edit_entity_find_replace(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "This is old content that needs updating",
},
@@ -704,7 +705,7 @@ async def test_edit_entity_find_replace_with_expected_replacements(
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
@@ -747,7 +748,7 @@ Original section 2 content"""
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": content,
},
@@ -794,7 +795,7 @@ async def test_edit_entity_invalid_operation(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -819,7 +820,7 @@ async def test_edit_entity_find_replace_missing_find_text(client: AsyncClient, p
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -844,7 +845,7 @@ async def test_edit_entity_replace_section_missing_section(client: AsyncClient,
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original content",
},
@@ -869,7 +870,7 @@ async def test_edit_entity_find_replace_not_found(client: AsyncClient, project_u
f"{project_url}/knowledge/entities",
json={
"title": "Test Note",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "This is some content",
},
@@ -894,7 +895,7 @@ async def test_edit_entity_find_replace_wrong_expected_count(client: AsyncClient
f"{project_url}/knowledge/entities",
json={
"title": "Sample Note",
"folder": "docs",
"directory": "docs",
"entity_type": "note",
"content": "The word banana appears here. Another banana word here.",
},
@@ -925,7 +926,7 @@ async def test_edit_entity_search_reindex(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "Search Test",
"folder": "test",
"directory": "test",
"entity_type": "note",
"content": "Original searchable content",
},
@@ -961,7 +962,7 @@ async def test_move_entity_success(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1006,7 +1007,7 @@ async def test_move_entity_with_folder_creation(client: AsyncClient, project_url
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"directory": "",
"entity_type": "note",
"content": "Test content",
},
@@ -1047,7 +1048,7 @@ Some additional content."""
f"{project_url}/knowledge/entities",
json={
"title": "ComplexEntity",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": content,
},
@@ -1098,7 +1099,7 @@ async def test_move_entity_search_reindexing(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SearchableNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Unique searchable elephant content",
},
@@ -1144,7 +1145,7 @@ async def test_move_entity_invalid_destination_path(client: AsyncClient, project
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "",
"directory": "",
"entity_type": "note",
"content": "Test content",
},
@@ -1177,7 +1178,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "SourceNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Source content",
},
@@ -1190,7 +1191,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "DestinationNote",
"folder": "target",
"directory": "target",
"entity_type": "note",
"content": "Destination content",
},
@@ -1235,7 +1236,7 @@ async def test_move_entity_by_file_path(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "TestNote",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1266,7 +1267,7 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
f"{project_url}/knowledge/entities",
json={
"title": "UniqueTestTitle",
"folder": "source",
"directory": "source",
"entity_type": "note",
"content": "Test content",
},
@@ -1287,3 +1288,119 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
moved_entity = response.json()
assert moved_entity["file_path"] == "target/MovedByTitle.md"
assert moved_entity["title"] == "UniqueTestTitle"
# --- Move directory tests ---
@pytest.mark.asyncio
async def test_move_directory_success(client: AsyncClient, project_url):
"""Test POST /move-directory endpoint successfully moves all files in a directory."""
# Create multiple notes in a source directory
for i in range(3):
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": f"DirMoveDoc{i + 1}",
"directory": "move-source",
"entity_type": "note",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Move the entire directory
move_data = {
"source_directory": "move-source",
"destination_directory": "move-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_moves == 3
assert result.failed_moves == 0
assert len(result.moved_files) == 3
# Verify notes are accessible at new location
for i in range(3):
response = await client.get(
f"{project_url}/knowledge/entities/move-dest/dir-move-doc{i + 1}"
)
assert response.status_code == 200
entity = response.json()
assert entity["file_path"].startswith("move-dest/")
@pytest.mark.asyncio
async def test_move_directory_empty_directory(client: AsyncClient, project_url):
"""Test move_directory with no files in source returns zero counts."""
move_data = {
"source_directory": "nonexistent-source-dir",
"destination_directory": "some-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_moves == 0
assert result.failed_moves == 0
assert len(result.moved_files) == 0
@pytest.mark.asyncio
async def test_move_directory_validation_error(client: AsyncClient, project_url):
"""Test move_directory with missing required fields returns validation error."""
# Missing destination_directory
move_data = {
"source_directory": "some-source",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 422
# Missing source_directory
move_data = {
"destination_directory": "some-dest",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_move_directory_nested_structure(client: AsyncClient, project_url):
"""Test move_directory preserves nested directory structure."""
# Create notes in nested structure
directories = [
"nested-move/2024",
"nested-move/2024/q1",
]
for dir_path in directories:
response = await client.post(
f"{project_url}/knowledge/entities",
json={
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"entity_type": "note",
"content": f"Content in {dir_path}",
},
)
assert response.status_code == 200
# Move the parent directory
move_data = {
"source_directory": "nested-move/2024",
"destination_directory": "archive/2024",
}
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 2
assert result.successful_moves == 2
# Verify nested note is at new location
response = await client.get(f"{project_url}/knowledge/entities/archive/2024/q1/note-in-q1")
assert response.status_code == 200
+7 -7
View File
@@ -133,7 +133,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor
content = "# Test Content\n\n- [note] an observation."
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content}",
}
@@ -173,7 +173,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -184,7 +184,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
@@ -229,7 +229,7 @@ async def test_get_resource_entities_pagination(
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -241,7 +241,7 @@ async def test_get_resource_entities_pagination(
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
@@ -281,7 +281,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content1}",
}
@@ -292,7 +292,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content": f"{content2}",
}
+1 -1
View File
@@ -132,7 +132,7 @@ async def test_reindex(
await entity_service.create_entity(
EntitySchema(
title="TestEntity1",
folder="test",
directory="test",
entity_type="test",
),
)
+16 -16
View File
@@ -159,7 +159,7 @@ async def test_import_chatgpt(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -192,7 +192,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, v2_pro
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request - this should return an error
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -217,7 +217,7 @@ async def test_import_claude_conversations(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request
response = await client.post(
@@ -254,7 +254,7 @@ async def test_import_claude_conversations_invalid_file(
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_conversations"}
data = {"directory": "test_claude_conversations"}
# Send request - this should return an error
response = await client.post(
@@ -277,7 +277,7 @@ async def test_import_claude_projects(
# Create a multipart form with the file
with open(file_path, "rb") as f:
files = {"file": ("projects.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request
response = await client.post(
@@ -319,7 +319,7 @@ async def test_import_claude_projects_invalid_file(
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_claude_projects"}
data = {"directory": "test_claude_projects"}
# Send request - this should return an error
response = await client.post(
@@ -345,7 +345,7 @@ async def test_import_memory_json(
# Create a multipart form with the file
with open(json_file, "rb") as f:
files = {"file": ("memory.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, v2
# Create multipart form with invalid file
with open(file_path, "rb") as f:
files = {"file": ("invalid.json", f, "application/json")}
data = {"folder": "test_memory_json"}
data = {"directory": "test_memory_json"}
# Send request - this should return an error
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
@@ -430,7 +430,7 @@ async def test_v2_import_endpoints_use_project_id_not_name(
# Try using project name instead of ID - should fail
with open(file_path, "rb") as f:
files = {"file": ("conversations.json", f, "application/json")}
data = {"folder": "test"}
data = {"directory": "test"}
response = await client.post(
f"/v2/projects/{test_project.name}/import/chatgpt",
@@ -459,7 +459,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
for endpoint in endpoints:
with open(file_path, "rb") as f:
files = {"file": ("test.json", f, "application/json")}
data = {"folder": "test"}
data = {"directory": "test"}
response = await client.post(
f"/v2/projects/999999{endpoint}",
@@ -474,7 +474,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
"""Test importing with missing file via v2 endpoint."""
# Send a request without a file
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"folder": "test_folder"})
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"directory": "test_folder"})
# Check that the request was rejected
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
@@ -491,7 +491,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, v2_project_url:
# Create multipart form with empty file
with open(file_path, "rb") as f:
files = {"file": ("empty.json", f, "application/json")}
data = {"folder": "test_chatgpt"}
data = {"directory": "test_chatgpt"}
# Send request
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
@@ -511,10 +511,10 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, v2_project_u
# Test all import endpoints
endpoints = [
(f"{v2_project_url}/import/chatgpt", {"folder": "test"}),
(f"{v2_project_url}/import/claude/conversations", {"folder": "test"}),
(f"{v2_project_url}/import/claude/projects", {"folder": "test"}),
(f"{v2_project_url}/import/memory-json", {"folder": "test"}),
(f"{v2_project_url}/import/chatgpt", {"directory": "test"}),
(f"{v2_project_url}/import/claude/conversations", {"directory": "test"}),
(f"{v2_project_url}/import/claude/projects", {"directory": "test"}),
(f"{v2_project_url}/import/memory-json", {"directory": "test"}),
]
for endpoint, data in endpoints:
+237 -11
View File
@@ -5,6 +5,7 @@ from httpx import AsyncClient
from basic_memory.models import Project
from basic_memory.schemas import DeleteEntitiesResponse
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
@@ -19,7 +20,7 @@ async def test_resolve_identifier_by_permalink(
# Create an entity first
entity_data = {
"title": "TestResolve",
"folder": "test",
"directory": "test",
"content": "Test content for resolve",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -51,13 +52,67 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that resolve uses strict mode - no fuzzy search fallback.
This ensures wiki links only resolve to exact matches (permalink, title, or path),
not to similar-sounding entities via fuzzy search.
"""
# Create an entity with a specific name
entity_data = {
"title": "link-test",
"folder": "testing",
"content": "A test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" - should NOT fuzzy match to "link-test"
resolve_data = {"identifier": "nonexistent"}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match to "link-test"
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that context-aware resolution also uses strict mode.
Even with source_path for context-aware resolution, nonexistent
links should return 404, not fuzzy match to nearby entities.
"""
# Create entities in a folder structure
entity_data = {
"title": "link-test",
"folder": "testing/nested",
"content": "A nested test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" with source_path context
# Should NOT fuzzy match to "link-test" in the same or nearby folder
resolve_data = {
"identifier": "nonexistent",
"source_path": "testing/nested/other-note.md",
}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
"""Test getting an entity by its external_id (UUID)."""
# Create an entity first
entity_data = {
"title": "TestGetById",
"folder": "test",
"directory": "test",
"content": "Test content for get by ID",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -94,7 +149,7 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
"""Test creating an entity via v2 endpoint."""
data = {
"title": "TestV2Entity",
"folder": "test",
"directory": "test",
"entity_type": "test",
"content_type": "text/markdown",
"content": "TestContent for V2",
@@ -127,7 +182,7 @@ async def test_create_entity_with_observations_and_relations(
"""Test creating an entity with observations and relations via v2."""
data = {
"title": "TestV2Complex",
"folder": "test",
"directory": "test",
"content": """
# TestV2Complex
@@ -164,7 +219,7 @@ async def test_update_entity_by_id(
# Create an entity first
create_data = {
"title": "TestUpdate",
"folder": "test",
"directory": "test",
"content": "Original content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -178,7 +233,7 @@ async def test_update_entity_by_id(
# Update it by external_id
update_data = {
"title": "TestUpdate",
"folder": "test",
"directory": "test",
"content": "Updated content via V2",
}
response = await client.put(
@@ -208,7 +263,7 @@ async def test_edit_entity_by_id_append(
# Create an entity first
create_data = {
"title": "TestEdit",
"folder": "test",
"directory": "test",
"content": "# TestEdit\n\nOriginal content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -251,7 +306,7 @@ async def test_edit_entity_by_id_find_replace(
# Create an entity first
create_data = {
"title": "TestFindReplace",
"folder": "test",
"directory": "test",
"content": "# TestFindReplace\n\nOld text that will be replaced",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -295,7 +350,7 @@ async def test_delete_entity_by_id(
# Create an entity first
create_data = {
"title": "TestDelete",
"folder": "test",
"directory": "test",
"content": "Content to be deleted",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -337,7 +392,7 @@ async def test_move_entity(client: AsyncClient, file_service, v2_project_url, en
# Create an entity first
create_data = {
"title": "TestMove",
"folder": "test",
"directory": "test",
"content": "Content to be moved",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
@@ -390,7 +445,7 @@ async def test_entity_response_v2_has_api_version(
# Create an entity
entity_data = {
"title": "TestApiVersion",
"folder": "test",
"directory": "test",
"content": "Test content",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
@@ -409,3 +464,174 @@ async def test_entity_response_v2_has_api_version(
entity_v2 = EntityResponseV2.model_validate(response.json())
assert entity_v2.api_version == "v2"
assert entity_v2.external_id == entity_external_id
# --- Move directory tests (V2) ---
@pytest.mark.asyncio
async def test_move_directory_v2_success(client: AsyncClient, v2_project_url):
"""Test POST /v2/.../move-directory endpoint successfully moves all files."""
# Create multiple notes in a source directory
for i in range(3):
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"V2DirMoveDoc{i + 1}",
"directory": "v2-move-source",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Move the entire directory
move_data = {
"source_directory": "v2-move-source",
"destination_directory": "v2-move-dest",
}
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_moves == 3
assert result.failed_moves == 0
assert len(result.moved_files) == 3
@pytest.mark.asyncio
async def test_move_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
"""Test move_directory V2 with no files in source returns zero counts."""
move_data = {
"source_directory": "v2-nonexistent-source",
"destination_directory": "v2-some-dest",
}
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
assert response.status_code == 200
result = DirectoryMoveResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_moves == 0
assert result.failed_moves == 0
@pytest.mark.asyncio
async def test_move_directory_v2_validation_error(client: AsyncClient, v2_project_url):
"""Test move_directory V2 with missing required fields returns validation error."""
# Missing destination_directory
response = await client.post(
f"{v2_project_url}/knowledge/move-directory",
json={"source_directory": "some-source"},
)
assert response.status_code == 422
# Missing source_directory
response = await client.post(
f"{v2_project_url}/knowledge/move-directory",
json={"destination_directory": "some-dest"},
)
assert response.status_code == 422
# --- Delete directory tests (V2) ---
@pytest.mark.asyncio
async def test_delete_directory_v2_success(client: AsyncClient, v2_project_url):
"""Test POST /v2/.../delete-directory endpoint successfully deletes all files."""
# Create multiple notes in a directory to delete
for i in range(3):
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"V2DeleteDoc{i + 1}",
"directory": "v2-delete-dir",
"content": f"Content for document {i + 1}",
},
)
assert response.status_code == 200
# Verify notes exist
created_entity = EntityResponseV2.model_validate(response.json())
get_response = await client.get(
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
)
assert get_response.status_code == 200
# Delete the entire directory
delete_data = {
"directory": "v2-delete-dir",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 3
assert result.successful_deletes == 3
assert result.failed_deletes == 0
assert len(result.deleted_files) == 3
# Verify entity is no longer accessible
get_response = await client.get(
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
)
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_delete_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 with no files returns zero counts."""
delete_data = {
"directory": "v2-nonexistent-delete-dir",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 0
assert result.successful_deletes == 0
assert result.failed_deletes == 0
@pytest.mark.asyncio
async def test_delete_directory_v2_validation_error(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 with missing required fields returns validation error."""
# Missing directory field
response = await client.post(
f"{v2_project_url}/knowledge/delete-directory",
json={},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_project_url):
"""Test delete_directory V2 handles nested directory structure."""
# Create notes in nested structure
directories = [
"v2-nested-delete/2024",
"v2-nested-delete/2024/q1",
]
for dir_path in directories:
response = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": f"Note in {dir_path.split('/')[-1]}",
"directory": dir_path,
"content": f"Content in {dir_path}",
},
)
assert response.status_code == 200
# Delete the parent directory
delete_data = {
"directory": "v2-nested-delete/2024",
}
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
assert response.status_code == 200
result = DirectoryDeleteResult.model_validate(response.json())
assert result.total_files == 2
assert result.successful_deletes == 2
assert result.failed_deletes == 0
+409
View File
@@ -0,0 +1,409 @@
"""Tests for cloud restore CLI commands.
SPEC-29 Phase 3: Tests for restore command.
"""
from unittest.mock import Mock, patch
import httpx
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
)
class TestRestoreCommand:
"""Tests for 'bm cloud restore' command."""
def test_restore_file_success_with_force(self):
"""Test successful file restoration with --force flag."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": ["notes/project.md"],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
assert "Successfully restored" in result.stdout
assert "notes/project.md" in result.stdout
def test_restore_folder_success(self):
"""Test successful folder restoration."""
runner = CliRunner()
mock_restore_response = Mock(spec=httpx.Response)
mock_restore_response.status_code = 200
mock_restore_response.json.return_value = {
"restored": [
"research/paper1.md",
"research/paper2.md",
"research/notes.md",
],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_restore_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
["cloud", "restore", "research/", "--snapshot", "snap_123", "--force"],
)
assert result.exit_code == 0
assert "Successfully restored 3 file(s)" in result.stdout
def test_restore_many_files_truncated_output(self):
"""Test restore output is truncated for many files."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": [f"notes/file{i}.md" for i in range(20)],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
["cloud", "restore", "notes/", "--snapshot", "snap_123", "--force"],
)
assert result.exit_code == 0
assert "Successfully restored 20 file(s)" in result.stdout
assert "and 15 more" in result.stdout
def test_restore_no_files_found(self):
"""Test restore when no files match the path."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": [],
"snapshot_id": "snap_123",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"nonexistent/",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
assert "No files were restored" in result.stdout
def test_restore_snapshot_not_found(self):
"""Test restore from non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_nonexistent",
"--force",
],
)
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
def test_restore_subscription_required(self):
"""Test restore requires subscription."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise SubscriptionRequiredError(
message="Active subscription required",
subscribe_url="https://basicmemory.com/subscribe",
)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 1
assert "Subscription Required" in result.stdout
assert "https://basicmemory.com/subscribe" in result.stdout
def test_restore_cancelled_by_user(self):
"""Test restore cancelled by user confirmation."""
runner = CliRunner()
# First call is browse, second would be restore (should not happen)
mock_browse_response = Mock(spec=httpx.Response)
mock_browse_response.status_code = 200
mock_browse_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"}
],
"prefix": "notes/project.md",
"snapshot_version": "12345",
}
call_count = 0
async def mock_make_api_request(method, url, *args, **kwargs):
nonlocal call_count
call_count += 1
if "browse" in url:
return mock_browse_response
# Track unexpected calls - the test will verify later
return mock_browse_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
# Simulate user saying "n" to confirmation
result = runner.invoke(
app,
["cloud", "restore", "notes/project.md", "--snapshot", "snap_123"],
input="n\n",
)
assert result.exit_code == 0
assert "cancelled" in result.stdout
# Only one call should happen (browse), not the restore POST
assert call_count == 1
def test_restore_with_leading_slash_normalized(self):
"""Test that leading slashes are stripped from path."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"restored": ["notes/project.md"],
"snapshot_id": "snap_123",
}
captured_json_data = []
async def mock_make_api_request(*args, **kwargs):
if "json_data" in kwargs:
captured_json_data.append(kwargs["json_data"])
return mock_response
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"/notes/project.md", # Leading slash
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 0
# Verify the path was normalized (no leading slash)
assert any(data.get("path") == "notes/project.md" for data in captured_json_data)
def test_restore_api_error(self):
"""Test handling generic API errors during restore."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Server error", status_code=500)
with patch(
"basic_memory.cli.commands.cloud.restore.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.restore.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app,
[
"cloud",
"restore",
"notes/project.md",
"--snapshot",
"snap_123",
"--force",
],
)
assert result.exit_code == 1
assert "Failed to restore" in result.stdout
class TestRestoreCommandHelp:
"""Tests for restore command help and usage."""
def test_restore_requires_snapshot_option(self):
"""Test that --snapshot option is required."""
runner = CliRunner()
result = runner.invoke(app, ["cloud", "restore", "notes/project.md"])
# Should fail due to missing required option (exit code 2 for usage error)
assert result.exit_code == 2
# Typer writes the error message to the output
assert "Missing option" in result.output or "--snapshot" in result.output
def test_restore_requires_path_argument(self):
"""Test that path argument is required."""
runner = CliRunner()
result = runner.invoke(app, ["cloud", "restore", "--snapshot", "snap_123"])
# Should fail due to missing required argument
assert result.exit_code != 0
+102
View File
@@ -0,0 +1,102 @@
"""Tests for CLI routing utilities."""
import os
import pytest
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
class TestValidateRoutingFlags:
"""Tests for validate_routing_flags function."""
def test_neither_flag(self):
"""Should not raise when neither flag is set."""
validate_routing_flags(local=False, cloud=False)
def test_local_only(self):
"""Should not raise when only local is set."""
validate_routing_flags(local=True, cloud=False)
def test_cloud_only(self):
"""Should not raise when only cloud is set."""
validate_routing_flags(local=False, cloud=True)
def test_both_flags_raises(self):
"""Should raise ValueError when both flags are set."""
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
validate_routing_flags(local=True, cloud=True)
class TestForceRouting:
"""Tests for force_routing context manager."""
def test_local_sets_env_var(self):
"""Local flag should set BASIC_MEMORY_FORCE_LOCAL."""
# Ensure env var is not set
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Should be cleaned up after context exits
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
def test_cloud_clears_env_var(self):
"""Cloud flag should clear BASIC_MEMORY_FORCE_LOCAL if set."""
# Set env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
with force_routing(cloud=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
# Should restore original value after context exits
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Cleanup
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_neither_flag_no_change(self):
"""Neither flag should not change env vars."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
with force_routing():
# Should not be set
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
# Should still not be set
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
def test_preserves_original_env_var(self):
"""Should restore original env var value after context exits."""
original_value = "original"
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_value
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
# Should restore original value
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == original_value
# Cleanup
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_both_flags_raises(self):
"""Should raise ValueError when both flags are set."""
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
with force_routing(local=True, cloud=True):
pass
def test_restores_on_exception(self):
"""Should restore env vars even when exception is raised."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
try:
with force_routing(local=True):
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
raise RuntimeError("Test exception")
except RuntimeError:
pass
# Should be cleaned up even after exception
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
+529
View File
@@ -0,0 +1,529 @@
"""Tests for cloud snapshot CLI commands.
SPEC-29 Phase 3: Tests for snapshot create, list, delete, show, browse commands.
"""
from unittest.mock import Mock, patch
import httpx
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
)
class TestSnapshotCreateCommand:
"""Tests for 'bm cloud snapshot create' command."""
def test_create_snapshot_success(self):
"""Test successful snapshot creation."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "snap_123",
"bucket_name": "tenant-abc",
"snapshot_version": "1703430000000",
"name": "manual-snapshot",
"description": "before major refactor",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "create", "before major refactor"]
)
assert result.exit_code == 0
assert "Snapshot created successfully" in result.stdout
assert "snap_123" in result.stdout
assert "before major refactor" in result.stdout
def test_create_snapshot_subscription_required(self):
"""Test snapshot creation requires subscription."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise SubscriptionRequiredError(
message="Active subscription required",
subscribe_url="https://basicmemory.com/subscribe",
)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
assert result.exit_code == 1
assert "Subscription Required" in result.stdout
assert "https://basicmemory.com/subscribe" in result.stdout
def test_create_snapshot_api_error(self):
"""Test handling API errors during snapshot creation."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Server error", status_code=500)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
assert result.exit_code == 1
assert "Failed to create snapshot" in result.stdout
class TestSnapshotListCommand:
"""Tests for 'bm cloud snapshot list' command."""
def test_list_snapshots_success(self):
"""Test successful snapshot listing."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"snapshots": [
{
"id": "snap_123",
"name": "snapshot-1",
"description": "first snapshot",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
},
{
"id": "snap_456",
"name": "daily-auto",
"description": "daily backup",
"auto": True,
"created_at": "2024-12-23T03:00:00Z",
},
],
"total": 2,
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list"])
assert result.exit_code == 0
assert "snap_123" in result.stdout
assert "snap_456" in result.stdout
assert "first snapshot" in result.stdout
assert "daily backup" in result.stdout
def test_list_snapshots_empty(self):
"""Test listing when no snapshots exist."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"snapshots": [], "total": 0}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list"])
assert result.exit_code == 0
assert "No snapshots found" in result.stdout
def test_list_snapshots_with_limit(self):
"""Test listing snapshots with custom limit."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"snapshots": [
{
"id": f"snap_{i}",
"name": f"snap-{i}",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
for i in range(20)
],
"total": 20,
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "list", "--limit", "5"])
assert result.exit_code == 0
# Should show message about more snapshots available
assert "Showing 5 of 20" in result.stdout
class TestSnapshotDeleteCommand:
"""Tests for 'bm cloud snapshot delete' command."""
def test_delete_snapshot_success_with_force(self):
"""Test successful snapshot deletion with --force flag."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "delete", "snap_123", "--force"])
assert result.exit_code == 0
assert "deleted successfully" in result.stdout
def test_delete_snapshot_not_found(self):
"""Test deletion of non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "delete", "snap_nonexistent", "--force"]
)
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
def test_delete_snapshot_cancelled(self):
"""Test snapshot deletion cancelled by user."""
runner = CliRunner()
# Mock successful GET for snapshot details
mock_get_response = Mock(spec=httpx.Response)
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"id": "snap_123",
"description": "test snapshot",
"created_at": "2024-12-24T12:00:00Z",
}
call_count = 0
async def mock_make_api_request(*args, **kwargs):
nonlocal call_count
call_count += 1
method = kwargs.get("method", args[0] if args else None)
if method == "GET":
return mock_get_response
# Track unexpected calls
return mock_get_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
# Simulate user saying "n" to confirmation
result = runner.invoke(
app, ["cloud", "snapshot", "delete", "snap_123"], input="n\n"
)
assert result.exit_code == 0
assert "cancelled" in result.stdout
# Only one call should happen (GET for details), not the DELETE
assert call_count == 1
class TestSnapshotShowCommand:
"""Tests for 'bm cloud snapshot show' command."""
def test_show_snapshot_success(self):
"""Test showing snapshot details."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "snap_123",
"bucket_name": "tenant-abc",
"snapshot_version": "1703430000000",
"name": "test-snapshot",
"description": "test description",
"auto": False,
"created_at": "2024-12-24T12:00:00Z",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_123"])
assert result.exit_code == 0
assert "snap_123" in result.stdout
assert "tenant-abc" in result.stdout
assert "test description" in result.stdout
def test_show_snapshot_not_found(self):
"""Test showing non-existent snapshot."""
runner = CliRunner()
async def mock_make_api_request(*args, **kwargs):
raise CloudAPIError("Not found", status_code=404)
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_nonexistent"])
assert result.exit_code == 1
assert "Snapshot not found" in result.stdout
class TestSnapshotBrowseCommand:
"""Tests for 'bm cloud snapshot browse' command."""
def test_browse_snapshot_success(self):
"""Test browsing snapshot contents."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "research/paper.md", "size": 4096, "last_modified": "2025-01-18T12:00:00Z"},
],
"prefix": "",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
assert result.exit_code == 0
assert "notes/project.md" in result.stdout
assert "notes/ideas.md" in result.stdout
assert "research/paper.md" in result.stdout
def test_browse_snapshot_with_prefix(self):
"""Test browsing snapshot with prefix filter."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
],
"prefix": "notes/",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
# Verify prefix is in the URL
url = args[1] if len(args) > 1 else kwargs.get("url", "")
assert "prefix=notes/" in url
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(
app, ["cloud", "snapshot", "browse", "snap_123", "--prefix", "notes/"]
)
assert result.exit_code == 0
def test_browse_snapshot_empty(self):
"""Test browsing snapshot with no files."""
runner = CliRunner()
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"files": [],
"prefix": "",
"snapshot_version": "12345",
}
async def mock_make_api_request(*args, **kwargs):
return mock_response
with patch(
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
side_effect=mock_make_api_request,
):
mock_config = Mock()
mock_config.cloud_host = "https://cloud.example.com"
mock_config_manager = Mock()
mock_config_manager.config = mock_config
with patch(
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
return_value=mock_config_manager,
):
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
assert result.exit_code == 0
assert "No files found" in result.stdout
+6 -6
View File
@@ -470,7 +470,7 @@ async def full_entity(sample_entity, entity_repository, file_service, entity_ser
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Search_Entity",
folder="test",
directory="test",
entity_type="test",
content=dedent("""
## Observations
@@ -502,7 +502,7 @@ async def test_graph(
EntitySchema(
title="Deeper Entity",
entity_type="deeper",
folder="test",
directory="test",
content=dedent("""
# Deeper Entity
"""),
@@ -513,7 +513,7 @@ async def test_graph(
EntitySchema(
title="Deep Entity",
entity_type="deep",
folder="test",
directory="test",
content=dedent("""
# Deep Entity
- deeper_connection [[Deeper Entity]]
@@ -525,7 +525,7 @@ async def test_graph(
EntitySchema(
title="Connected Entity 2",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Connected Entity 2
- deep_connection [[Deep Entity]]
@@ -537,7 +537,7 @@ async def test_graph(
EntitySchema(
title="Connected Entity 1",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Connected Entity 1
- [note] Connected 1 note
@@ -550,7 +550,7 @@ async def test_graph(
EntitySchema(
title="Root",
entity_type="test",
folder="test",
directory="test",
content=dedent("""
# Root Entity
- [note] Root note 1
@@ -0,0 +1,79 @@
"""Tests for async_client force_local_mode functionality."""
import os
from basic_memory.mcp.async_client import _force_local_mode
class TestForceLocalMode:
"""Tests for _force_local_mode function."""
def test_returns_false_when_not_set(self):
"""Should return False when env var is not set."""
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
assert _force_local_mode() is False
def test_returns_true_for_true(self):
"""Should return True when env var is 'true'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_1(self):
"""Should return True when env var is '1'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "1"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_yes(self):
"""Should return True when env var is 'yes'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "yes"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_true_for_TRUE_uppercase(self):
"""Should return True when env var is 'TRUE' (case insensitive)."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "TRUE"
try:
assert _force_local_mode() is True
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_false(self):
"""Should return False when env var is 'false'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "false"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_0(self):
"""Should return False when env var is '0'."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "0"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_empty(self):
"""Should return False when env var is empty string."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = ""
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
def test_returns_false_for_random_string(self):
"""Should return False when env var is random string."""
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "random"
try:
assert _force_local_mode() is False
finally:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
+8 -8
View File
@@ -12,7 +12,7 @@ async def test_write_note_tags_yaml_format(app, project_config, test_project):
result = await write_note.fn(
project=test_project.name,
title="YAML Format Test",
folder="test",
directory="test",
content="Testing YAML tag formatting",
tags=["system", "overview", "reference"],
)
@@ -44,7 +44,7 @@ async def test_write_note_stringified_json_tags(app, project_config, test_projec
result = await write_note.fn(
project=test_project.name,
title="Stringified JSON Test",
folder="test",
directory="test",
content="Testing stringified JSON tag input",
tags='["python", "testing", "json"]', # Stringified JSON array
)
@@ -74,7 +74,7 @@ async def test_write_note_single_tag_yaml_format(app, project_config, test_proje
await write_note.fn(
project=test_project.name,
title="Single Tag Test",
folder="test",
directory="test",
content="Testing single tag formatting",
tags=["solo-tag"],
)
@@ -93,7 +93,7 @@ async def test_write_note_no_tags(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="No Tags Test",
folder="test",
directory="test",
content="Testing note without tags",
tags=None,
)
@@ -112,7 +112,7 @@ async def test_write_note_empty_tags_list(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="Empty Tags Test",
folder="test",
directory="test",
content="Testing empty tag list",
tags=[],
)
@@ -131,7 +131,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
await write_note.fn(
project=test_project.name,
title="Update Format Test",
folder="test",
directory="test",
content="Initial content",
tags=["initial", "tag"],
)
@@ -140,7 +140,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
result = await write_note.fn(
project=test_project.name,
title="Update Format Test",
folder="test",
directory="test",
content="Updated content",
tags=["updated", "new-tag", "format"],
)
@@ -173,7 +173,7 @@ async def test_complex_tags_yaml_format(app, project_config, test_project):
await write_note.fn(
project=test_project.name,
title="Complex Tags Test",
folder="test",
directory="test",
content="Testing complex tag formats",
tags=["python-3.9", "api_integration", "v2.0", "nested/category", "under_score"],
)
@@ -64,7 +64,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_a = await write_note.fn(
project=test_project.name,
title="Node A",
folder="edge-cases",
directory="edge-cases",
content="# Node A\n\nOriginal content for Node A\n\n## Relations\n- links_to [[Node B]]",
)
@@ -81,7 +81,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_b = await write_note.fn(
project=test_project.name,
title="Node B",
folder="edge-cases",
directory="edge-cases",
content="# Node B\n\nContent for Node B",
)
@@ -93,7 +93,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
result_c = await write_note.fn(
project=test_project.name,
title="Node C",
folder="edge-cases",
directory="edge-cases",
content="# Node C\n\nContent for Node C\n\n## Relations\n- links_to [[Node A]]",
)
@@ -165,7 +165,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
result = await write_note.fn(
project=test_project.name,
title=title,
folder=folder,
directory=folder,
content=f"# {title}\n\nUnique content for {title}",
)
@@ -210,7 +210,7 @@ async def test_sequential_note_creation_preserves_all_files(app, test_project):
result = await write_note.fn(
project=test_project.name,
title=title,
folder="sequence-test",
directory="sequence-test",
content=content,
)
assert "# Created note" in result or "# Updated note" in result
+8 -6
View File
@@ -153,8 +153,9 @@ async def test_recent_activity_prompt_discovery_mode(client, test_project, test_
result = await recent_activity_prompt.fn(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected discovery mode content
assert "Recent Activity Across All Projects" in result # pyright: ignore [reportOperatorIssue]
assert "Cross-Project Activity Discovery" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
assert "all projects" in result # pyright: ignore [reportOperatorIssue]
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
@@ -165,9 +166,9 @@ async def test_recent_activity_prompt_project_specific(client, test_project, tes
result = await recent_activity_prompt.fn(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected project-specific content
assert f"Recent Activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
assert "Opportunity to Capture Activity Summary" in result # pyright: ignore [reportOperatorIssue]
assert f"recent activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
assert test_project.name in result # pyright: ignore [reportOperatorIssue]
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
@@ -178,4 +179,5 @@ async def test_recent_activity_prompt_with_custom_timeframe(client, test_project
result = await recent_activity_prompt.fn(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues]
# Check the response includes the custom timeframe
assert "Recent Activity Across All Projects (1d)" in result # pyright: ignore [reportOperatorIssue]
assert "1d" in result # pyright: ignore [reportOperatorIssue]
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
+68 -88
View File
@@ -1,111 +1,91 @@
from datetime import UTC, datetime
"""Tests for recent_activity_prompt with different modes.
The prompt delegates to the recent_activity tool which returns a formatted string.
These tests verify the prompt correctly uses the tool output and adds guidance.
"""
import pytest
from basic_memory.mcp.prompts.recent_activity import recent_activity_prompt
from basic_memory.schemas.memory import (
ActivityStats,
ContextResult,
GraphContext,
MemoryMetadata,
ProjectActivity,
ProjectActivitySummary,
EntitySummary,
)
from basic_memory.schemas.search import SearchItemType
def _entity(title: str, entity_id: int = 1) -> EntitySummary:
return EntitySummary(
entity_id=entity_id,
permalink=title.lower().replace(" ", "-"),
title=title,
content=None,
file_path=f"{title}.md",
created_at=datetime.now(UTC),
)
@pytest.mark.asyncio
async def test_recent_activity_prompt_discovery_mode(monkeypatch):
recent = ProjectActivitySummary(
projects={
"p1": ProjectActivity(
project_name="p1",
project_path="/tmp/p1",
activity=GraphContext(
results=[
ContextResult(
primary_result=_entity("A"), observations=[], related_results=[]
)
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
depth=1,
timeframe="7d",
generated_at=datetime.now(UTC),
),
),
item_count=1,
),
"p2": ProjectActivity(
project_name="p2",
project_path="/tmp/p2",
activity=GraphContext(
results=[
ContextResult(
primary_result=_entity("B", 2), observations=[], related_results=[]
)
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
depth=1,
timeframe="7d",
generated_at=datetime.now(UTC),
),
),
item_count=1,
),
},
summary=ActivityStats(
total_projects=2, active_projects=2, most_active_project="p1", total_items=2
),
timeframe="7d",
generated_at=datetime.now(UTC),
)
"""Test prompt in discovery mode (no project specified)."""
# The tool returns a formatted string in discovery mode
tool_output = """## Recent Activity Summary (7d)
**Most Active Project:** project-alpha (5 items)
- 🔧 **Latest:** API Design Decision (2 hours ago)
- 📋 **Focus areas:** decisions, specs
**Other Active Projects:**
- **project-beta** (3 items)
**Summary:** 2 active projects, 8 recent items
"""
async def fake_fn(**_kwargs):
return recent
return tool_output
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
out = await recent_activity_prompt.fn(timeframe="7d", project=None) # pyright: ignore[reportGeneralTypeIssues]
assert "Recent Activity Across All Projects" in out
assert "Cross-Project Activity Discovery" in out
# Should contain the tool output
assert "Recent Activity Summary (7d)" in out
assert "Most Active Project" in out
# Should contain prompt guidance
assert "Recent Activity Context" in out
assert "Next Steps" in out
assert "Capture Opportunity" in out
@pytest.mark.asyncio
async def test_recent_activity_prompt_project_mode(monkeypatch):
recent = GraphContext(
results=[
ContextResult(primary_result=_entity("Only"), observations=[], related_results=[])
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
depth=1,
timeframe="1d",
generated_at=datetime.now(UTC),
),
)
"""Test prompt in project-specific mode."""
# The tool returns a formatted string for a specific project
tool_output = """## Recent Activity: my-project (1d)
**📄 Recent Notes & Documents (3):**
API Design Decision
User Authentication Spec
Database Schema
**Activity Summary:** 3 items found
"""
async def fake_fn(**_kwargs):
return recent
return tool_output
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
out = await recent_activity_prompt.fn(timeframe="1d", project="proj") # pyright: ignore[reportGeneralTypeIssues]
assert "Recent Activity in proj" in out
assert "Opportunity to Capture Activity Summary" in out
out = await recent_activity_prompt.fn(timeframe="1d", project="my-project") # pyright: ignore[reportGeneralTypeIssues]
# Should contain the tool output
assert "Recent Activity: my-project" in out
assert "Recent Notes & Documents" in out
# Should contain prompt guidance
assert "Recent Activity Context" in out
assert "my-project" in out # Project mentioned in guidance
assert "Next Steps" in out
@pytest.mark.asyncio
async def test_recent_activity_prompt_passes_correct_params(monkeypatch):
"""Test that prompt passes correct parameters to the tool."""
captured_kwargs = {}
async def fake_fn(**kwargs):
captured_kwargs.update(kwargs)
return "## Recent Activity"
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
await recent_activity_prompt.fn(timeframe="2d", project="test-proj") # pyright: ignore[reportGeneralTypeIssues]
assert captured_kwargs["timeframe"] == "2d"
assert captured_kwargs["project"] == "test-proj"
assert captured_kwargs["type"] == "entity"
+6 -6
View File
@@ -35,7 +35,7 @@ async def test_create_canvas(app, project_config, test_project):
# Execute
result = await canvas.fn(
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
# Verify result message
@@ -74,7 +74,7 @@ async def test_create_canvas_with_extension(app, project_config, test_project):
# Execute
result = await canvas.fn(
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
# Verify
@@ -109,7 +109,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
folder = "visualizations"
# Create initial canvas
await canvas.fn(project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder)
await canvas.fn(project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder)
# Verify file exists
file_path = Path(project_config.home) / folder / f"{title}.canvas"
@@ -137,7 +137,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
nodes=updated_nodes,
edges=updated_edges,
title=title,
folder=folder,
directory=folder,
)
# Verify result indicates update
@@ -170,7 +170,7 @@ async def test_create_canvas_with_nested_folders(app, project_config, test_proje
# Execute
result = await canvas.fn(
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
# Verify
@@ -255,7 +255,7 @@ async def test_create_canvas_complex_content(app, project_config, test_project):
# Execute
result = await canvas.fn(
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
# Verify
+16 -16
View File
@@ -13,7 +13,7 @@ async def test_edit_note_append_operation(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\nOriginal content here.",
)
@@ -41,7 +41,7 @@ async def test_edit_note_prepend_operation(client, test_project):
await write_note.fn(
project=test_project.name,
title="Meeting Notes",
folder="meetings",
directory="meetings",
content="# Meeting Notes\nExisting content.",
)
@@ -69,7 +69,7 @@ async def test_edit_note_find_replace_operation(client, test_project):
await write_note.fn(
project=test_project.name,
title="Config Document",
folder="config",
directory="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
@@ -98,7 +98,7 @@ async def test_edit_note_replace_section_operation(client, test_project):
await write_note.fn(
project=test_project.name,
title="API Specification",
folder="specs",
directory="specs",
content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.",
)
@@ -142,7 +142,7 @@ async def test_edit_note_invalid_operation(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nContent here.",
)
@@ -164,7 +164,7 @@ async def test_edit_note_find_replace_missing_find_text(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nContent here.",
)
@@ -186,7 +186,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nContent here.",
)
@@ -208,7 +208,7 @@ async def test_edit_note_replace_section_nonexistent_section(client, test_projec
await write_note.fn(
project=test_project.name,
title="Document",
folder="docs",
directory="docs",
content="# Document\n\n## Existing Section\nSome content here.",
)
@@ -236,7 +236,7 @@ async def test_edit_note_with_observations_and_relations(client, test_project):
await write_note.fn(
project=test_project.name,
title="Feature Spec",
folder="features",
directory="features",
content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.",
)
@@ -261,7 +261,7 @@ async def test_edit_note_identifier_variations(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Document",
folder="docs",
directory="docs",
content="# Test Document\nOriginal content.",
)
@@ -293,7 +293,7 @@ async def test_edit_note_find_replace_no_matches(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\nSome content here.",
)
@@ -319,7 +319,7 @@ async def test_edit_note_empty_content_operations(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\nOriginal content.",
)
@@ -340,7 +340,7 @@ async def test_edit_note_find_replace_wrong_count(client, test_project):
await write_note.fn(
project=test_project.name,
title="Config Document",
folder="config",
directory="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
@@ -369,7 +369,7 @@ async def test_edit_note_replace_section_multiple_sections(client, test_project)
await write_note.fn(
project=test_project.name,
title="Sample Note",
folder="docs",
directory="docs",
content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance",
)
@@ -396,7 +396,7 @@ async def test_edit_note_find_replace_empty_find_text(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\nSome content here.",
)
@@ -426,7 +426,7 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\nOriginal content here.",
)
+3 -3
View File
@@ -139,7 +139,7 @@ async def test_list_directory_with_created_notes(client, test_project):
await write_note.fn(
project=test_project.name,
title="Project Planning",
folder="projects",
directory="projects",
content="# Project Planning\nThis is about planning projects.",
tags=["planning", "project"],
)
@@ -147,7 +147,7 @@ async def test_list_directory_with_created_notes(client, test_project):
await write_note.fn(
project=test_project.name,
title="Meeting Notes",
folder="projects",
directory="projects",
content="# Meeting Notes\nNotes from the meeting.",
tags=["meeting", "notes"],
)
@@ -155,7 +155,7 @@ async def test_list_directory_with_created_notes(client, test_project):
await write_note.fn(
project=test_project.name,
title="Research Document",
folder="research",
directory="research",
content="# Research\nSome research findings.",
tags=["research"],
)
+28 -28
View File
@@ -42,7 +42,7 @@ async def test_move_note_success(app, client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nOriginal content here.",
)
@@ -77,7 +77,7 @@ async def test_move_note_with_folder_creation(client, test_project):
await write_note.fn(
project=test_project.name,
title="Deep Note",
folder="",
directory="",
content="# Deep Note\nContent in root folder.",
)
@@ -104,7 +104,7 @@ async def test_move_note_with_observations_and_relations(app, client, test_proje
await write_note.fn(
project=test_project.name,
title="Complex Entity",
folder="source",
directory="source",
content="""# Complex Entity
## Observations
@@ -145,7 +145,7 @@ async def test_move_note_by_title(client, test_project):
await write_note.fn(
project=test_project.name,
title="UniqueTestTitle",
folder="source",
directory="source",
content="# UniqueTestTitle\nTest content.",
)
@@ -172,7 +172,7 @@ async def test_move_note_by_file_path(client, test_project):
await write_note.fn(
project=test_project.name,
title="PathTest",
folder="source",
directory="source",
content="# PathTest\nContent for path test.",
)
@@ -215,7 +215,7 @@ async def test_move_note_invalid_destination_path(client, test_project):
await write_note.fn(
project=test_project.name,
title="TestNote",
folder="source",
directory="source",
content="# TestNote\nTest content.",
)
@@ -239,7 +239,7 @@ async def test_move_note_missing_file_extension(client, test_project):
await write_note.fn(
project=test_project.name,
title="ExtensionTest",
folder="source",
directory="source",
content="# Extension Test\nTesting extension validation.",
)
@@ -281,7 +281,7 @@ async def test_move_note_file_extension_mismatch(client, test_project):
await write_note.fn(
project=test_project.name,
title="MarkdownNote",
folder="source",
directory="source",
content="# Markdown Note\nThis is a markdown file.",
)
@@ -313,7 +313,7 @@ async def test_move_note_preserves_file_extension(client, test_project):
await write_note.fn(
project=test_project.name,
title="PreserveExtension",
folder="source",
directory="source",
content="# Preserve Extension\nTesting that extension is preserved.",
)
@@ -348,7 +348,7 @@ async def test_move_note_destination_exists(client, test_project):
await write_note.fn(
project=test_project.name,
title="SourceNote",
folder="source",
directory="source",
content="# SourceNote\nSource content.",
)
@@ -356,7 +356,7 @@ async def test_move_note_destination_exists(client, test_project):
await write_note.fn(
project=test_project.name,
title="DestinationNote",
folder="target",
directory="target",
content="# DestinationNote\nDestination content.",
)
@@ -380,7 +380,7 @@ async def test_move_note_same_location(client, test_project):
await write_note.fn(
project=test_project.name,
title="SameLocationTest",
folder="test",
directory="test",
content="# SameLocationTest\nContent here.",
)
@@ -404,7 +404,7 @@ async def test_move_note_rename_only(client, test_project):
await write_note.fn(
project=test_project.name,
title="OriginalName",
folder="test",
directory="test",
content="# OriginalName\nContent to rename.",
)
@@ -436,7 +436,7 @@ async def test_move_note_complex_filename(client, test_project):
await write_note.fn(
project=test_project.name,
title="Meeting Notes 2025",
folder="meetings",
directory="meetings",
content="# Meeting Notes 2025\nMeeting content with dates.",
)
@@ -465,7 +465,7 @@ async def test_move_note_with_tags(app, client, test_project):
await write_note.fn(
project=test_project.name,
title="Tagged Note",
folder="source",
directory="source",
content="# Tagged Note\nContent with tags.",
tags=["important", "work", "project"],
)
@@ -494,7 +494,7 @@ async def test_move_note_empty_string_destination(client, test_project):
await write_note.fn(
project=test_project.name,
title="TestNote",
folder="source",
directory="source",
content="# TestNote\nTest content.",
)
@@ -518,7 +518,7 @@ async def test_move_note_parent_directory_path(client, test_project):
await write_note.fn(
project=test_project.name,
title="TestNote",
folder="source",
directory="source",
content="# TestNote\nTest content.",
)
@@ -542,7 +542,7 @@ async def test_move_note_identifier_variations(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Document",
folder="docs",
directory="docs",
content="# Test Document\nContent for testing identifiers.",
)
@@ -569,7 +569,7 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
await write_note.fn(
project=test_project.name,
title="Custom Frontmatter Note",
folder="source",
directory="source",
content="# Custom Frontmatter Note\nContent with custom metadata.",
)
@@ -641,7 +641,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -675,7 +675,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -708,7 +708,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -743,7 +743,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -776,7 +776,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -807,7 +807,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -844,7 +844,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -868,7 +868,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
@@ -890,7 +890,7 @@ class TestMoveNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="source",
directory="source",
content="# Test Note\nTest content for security testing.",
)
+1 -1
View File
@@ -148,7 +148,7 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
await write_note.fn(
project=test_project.name,
title="Meeting",
folder="notes",
directory="notes",
content="This is a safe note for read_content()",
)
+8 -8
View File
@@ -14,7 +14,7 @@ async def test_read_note_by_title(app, test_project):
"""Test reading a note by its title."""
# First create a note
await write_note.fn(
project=test_project.name, title="Special Note", folder="test", content="Note content here"
project=test_project.name, title="Special Note", directory="test", content="Note content here"
)
# Should be able to read it by title
@@ -28,7 +28,7 @@ async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch,
await write_note.fn(
project=test_project.name,
title="Fallback Title Note",
folder="test",
directory="test",
content="fallback content",
)
@@ -109,7 +109,7 @@ async def test_note_unicode_content(app, test_project):
"""Test handling of unicode content in"""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
result = await write_note.fn(
project=test_project.name, title="Unicode Test", folder="test", content=content
project=test_project.name, title="Unicode Test", directory="test", content=content
)
# Check that note was created (checksum is now "unknown" in v2)
@@ -136,7 +136,7 @@ async def test_multiple_notes(app, test_project):
for _, title, folder, content, tags in notes_data:
await write_note.fn(
project=test_project.name, title=title, folder=folder, content=content, tags=tags
project=test_project.name, title=title, directory=folder, content=content, tags=tags
)
# Should be able to read each one individually
@@ -161,7 +161,7 @@ async def test_multiple_notes_pagination(app, test_project):
for _, title, folder, content, tags in notes_data:
await write_note.fn(
project=test_project.name, title=title, folder=folder, content=content, tags=tags
project=test_project.name, title=title, directory=folder, content=content, tags=tags
)
# Should be able to read each one individually with pagination
@@ -187,7 +187,7 @@ async def test_read_note_memory_url(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Memory URL Test",
folder="test",
directory="test",
content="Testing memory:// URL handling",
)
assert result
@@ -365,7 +365,7 @@ class TestReadNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Security Test Note",
folder="security-tests",
directory="security-tests",
content="# Security Test Note\nThis is a legitimate note for security testing.",
)
@@ -423,7 +423,7 @@ class TestReadNoteSecurityValidation:
await write_note.fn(
project=test_project.name,
title="Full Feature Security Test Note",
folder="security-tests",
directory="security-tests",
content=dedent("""
# Full Feature Security Test Note
+2 -2
View File
@@ -170,8 +170,8 @@ async def test_recent_activity_discovery_mode_multiple_active_projects(
)
assert result.startswith("")
await write_note.fn(project=test_project.name, title="One", folder="notes", content="one")
await write_note.fn(project="second-project", title="Two", folder="notes", content="two")
await write_note.fn(project=test_project.name, title="One", directory="notes", content="one")
await write_note.fn(project="second-project", title="Two", directory="notes", content="two")
out = await recent_activity.fn()
assert "Recent Activity Summary" in out
+3 -3
View File
@@ -28,7 +28,7 @@ async def test_read_file_text_file(app, synced_files, test_project):
result = await write_note.fn(
project=test_project.name,
title="Text Resource",
folder="test",
directory="test",
content="This is a test text resource",
tags=["test", "resource"],
)
@@ -56,7 +56,7 @@ async def test_read_content_file_path(app, synced_files, test_project):
result = await write_note.fn(
project=test_project.name,
title="Text Resource",
folder="test",
directory="test",
content="This is a test text resource",
tags=["test", "resource"],
)
@@ -138,7 +138,7 @@ async def test_read_file_memory_url(app, synced_files, test_project):
await write_note.fn(
project=test_project.name,
title="Memory URL Test",
folder="test",
directory="test",
content="Testing memory:// URL handling for resources",
)
+8 -8
View File
@@ -15,7 +15,7 @@ async def test_search_text(client, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Search Note",
folder="test",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
@@ -41,7 +41,7 @@ async def test_search_title(client, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Search Note",
folder="test",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
@@ -69,7 +69,7 @@ async def test_search_permalink(client, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Search Note",
folder="test",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
@@ -97,7 +97,7 @@ async def test_search_permalink_match(client, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Search Note",
folder="test",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
@@ -125,7 +125,7 @@ async def test_search_pagination(client, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Search Note",
folder="test",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
@@ -153,7 +153,7 @@ async def test_search_with_type_filter(client, test_project):
await write_note.fn(
project=test_project.name,
title="Entity Type Test",
folder="test",
directory="test",
content="# Test\nFiltered by type",
)
@@ -176,7 +176,7 @@ async def test_search_with_entity_type_filter(client, test_project):
await write_note.fn(
project=test_project.name,
title="Entity Type Test",
folder="test",
directory="test",
content="# Test\nFiltered by type",
)
@@ -201,7 +201,7 @@ async def test_search_with_date_filter(client, test_project):
await write_note.fn(
project=test_project.name,
title="Recent Note",
folder="test",
directory="test",
content="# Test\nRecent content",
)
+12 -12
View File
@@ -14,7 +14,7 @@ async def test_view_note_basic_functionality(app, test_project):
await write_note.fn(
project=test_project.name,
title="Test View Note",
folder="test",
directory="test",
content="# Test View Note\n\nThis is test content for viewing.",
)
@@ -48,7 +48,7 @@ async def test_view_note_with_frontmatter_title(app, test_project):
""").strip()
await write_note.fn(
project=test_project.name, title="Frontmatter Title", folder="test", content=content
project=test_project.name, title="Frontmatter Title", directory="test", content=content
)
# View the note
@@ -66,7 +66,7 @@ async def test_view_note_with_heading_title(app, test_project):
content = "# Heading Title\n\nContent with heading title."
await write_note.fn(
project=test_project.name, title="Heading Title", folder="test", content=content
project=test_project.name, title="Heading Title", directory="test", content=content
)
# View the note
@@ -83,7 +83,7 @@ async def test_view_note_unicode_content(app, test_project):
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
await write_note.fn(
project=test_project.name, title="Unicode Test 🚀", folder="test", content=content
project=test_project.name, title="Unicode Test 🚀", directory="test", content=content
)
# View the note
@@ -102,7 +102,7 @@ async def test_view_note_by_permalink(app, test_project):
await write_note.fn(
project=test_project.name,
title="Permalink Test",
folder="test",
directory="test",
content="Content for permalink test.",
)
@@ -121,7 +121,7 @@ async def test_view_note_with_memory_url(app, test_project):
await write_note.fn(
project=test_project.name,
title="Memory URL Test",
folder="test",
directory="test",
content="Testing memory:// URL handling in view_note",
)
@@ -154,7 +154,7 @@ async def test_view_note_pagination(app, test_project):
await write_note.fn(
project=test_project.name,
title="Pagination Test",
folder="test",
directory="test",
content="Content for pagination test.",
)
@@ -173,7 +173,7 @@ async def test_view_note_project_parameter(app, test_project):
await write_note.fn(
project=test_project.name,
title="Project Test",
folder="test",
directory="test",
content="Content for project test.",
)
@@ -191,10 +191,10 @@ async def test_view_note_artifact_identifier_unique(app, test_project):
"""Test that different notes are retrieved correctly with unique identifiers."""
# Create two notes
await write_note.fn(
project=test_project.name, title="Note One", folder="test", content="Content one"
project=test_project.name, title="Note One", directory="test", content="Content one"
)
await write_note.fn(
project=test_project.name, title="Note Two", folder="test", content="Content two"
project=test_project.name, title="Note Two", directory="test", content="Content two"
)
# View both notes
@@ -215,7 +215,7 @@ async def test_view_note_fallback_identifier_as_title(app, test_project):
await write_note.fn(
project=test_project.name,
title="Simple Note",
folder="test",
directory="test",
content="Just plain content with no headings or frontmatter title",
)
@@ -233,7 +233,7 @@ async def test_view_note_direct_success(app, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test Note\n\nThis is a test note.",
)
+39 -39
View File
@@ -20,7 +20,7 @@ async def test_write_note(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
@@ -60,7 +60,7 @@ async def test_write_note(app, test_project):
async def test_write_note_no_tags(app, test_project):
"""Test creating a note without tags."""
result = await write_note.fn(
project=test_project.name, title="Simple Note", folder="test", content="Just some text"
project=test_project.name, title="Simple Note", directory="test", content="Just some text"
)
assert result
@@ -100,7 +100,7 @@ async def test_write_note_update_existing(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
@@ -117,7 +117,7 @@ async def test_write_note_update_existing(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nThis is an updated note",
tags=["test", "documentation"],
)
@@ -173,7 +173,7 @@ async def test_issue_93_write_note_respects_custom_permalink_new_note(app, test_
result = await write_note.fn(
project=test_project.name,
title="My New Note",
folder="notes",
directory="notes",
content=content_with_custom_permalink,
)
@@ -193,7 +193,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
result1 = await write_note.fn(
project=test_project.name,
title="Existing Note",
folder="test",
directory="test",
content="Initial content without custom permalink",
)
@@ -225,7 +225,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
result2 = await write_note.fn(
project=test_project.name,
title="Existing Note",
folder="test",
directory="test",
content=updated_content,
)
@@ -249,7 +249,7 @@ async def test_delete_note_existing(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
@@ -284,7 +284,7 @@ async def test_write_note_with_tag_array_from_bug_report(app, test_project):
bug_payload = {
"project": test_project.name,
"title": "Title",
"folder": "folder",
"directory": "folder",
"content": "CONTENT",
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"],
}
@@ -313,7 +313,7 @@ async def test_write_note_verbose(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content="""
# Test\nThis is a test note
@@ -352,7 +352,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
await write_note.fn(
project=test_project.name,
title="Custom Metadata Note",
folder="test",
directory="test",
content="# Initial content",
tags=["test"],
)
@@ -380,7 +380,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
result = await write_note.fn(
project=test_project.name,
title="Custom Metadata Note",
folder="test",
directory="test",
content="# Updated content",
tags=["test", "updated"],
)
@@ -415,7 +415,7 @@ async def test_write_note_preserves_content_frontmatter(app, test_project):
await write_note.fn(
project=test_project.name,
title="Test Note",
folder="test",
directory="test",
content=dedent(
"""
---
@@ -475,7 +475,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
result1 = await write_note.fn(
project=test_project.name,
title="Note 1",
folder="test",
directory="test",
content="Original content for note 1",
)
assert "# Created note" in result1
@@ -484,7 +484,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
# Step 2: Create second note with different title
result2 = await write_note.fn(
project=test_project.name, title="Note 2", folder="test", content="Content for note 2"
project=test_project.name, title="Note 2", directory="test", content="Content for note 2"
)
assert "# Created note" in result2
assert f"project: {test_project.name}" in result2
@@ -495,7 +495,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
result3 = await write_note.fn(
project=test_project.name,
title="Note 1", # Same title as first note
folder="test", # Same folder as first note
directory="test", # Same folder as first note
content="Replacement content for note 1", # Different content
)
@@ -535,7 +535,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Test Guide",
folder="guides",
directory="guides",
content="# Guide Content\nThis is a guide",
tags=["guide", "documentation"],
note_type="guide",
@@ -578,7 +578,7 @@ async def test_write_note_with_report_entity_type(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Monthly Report",
folder="reports",
directory="reports",
content="# Monthly Report\nThis is a monthly report",
tags=["report", "monthly"],
note_type="report",
@@ -603,7 +603,7 @@ async def test_write_note_with_config_entity_type(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="System Config",
folder="config",
directory="config",
content="# System Configuration\nThis is a config file",
note_type="config",
)
@@ -631,7 +631,7 @@ async def test_write_note_entity_type_default_behavior(app, test_project):
result = await write_note.fn(
project=test_project.name,
title="Default Type Test",
folder="test",
directory="test",
content="# Default Type Test\nThis should be type 'note'",
tags=["test"],
)
@@ -656,7 +656,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
result1 = await write_note.fn(
project=test_project.name,
title="Changeable Type",
folder="test",
directory="test",
content="# Initial Content\nThis starts as a note",
tags=["test"],
note_type="note",
@@ -670,7 +670,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
result2 = await write_note.fn(
project=test_project.name,
title="Changeable Type",
folder="test",
directory="test",
content="# Updated Content\nThis is now a guide",
tags=["guide"],
note_type="guide",
@@ -711,7 +711,7 @@ async def test_write_note_respects_frontmatter_entity_type(app, test_project):
# Call write_note without entity_type parameter - it should respect frontmatter type
result = await write_note.fn(
project=test_project.name, title="Test Guide", folder="guides", content=note
project=test_project.name, title="Test Guide", directory="guides", content=note
)
assert result
@@ -752,7 +752,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder=attack_folder,
directory=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
@@ -781,7 +781,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder=attack_folder,
directory=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
@@ -810,7 +810,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder=attack_folder,
directory=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
@@ -838,7 +838,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder=attack_folder,
directory=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
@@ -864,7 +864,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder=attack_folder,
directory=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
@@ -891,7 +891,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title=f"Test Note in {safe_folder.replace('/', '-')}",
folder=safe_folder,
directory=safe_folder,
content="# Test Content\nThis should work normally with security validation.",
tags=["test", "security"],
)
@@ -911,7 +911,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Root Note",
folder="",
directory="",
content="# Root Note\nThis note should be created in the project root.",
)
@@ -930,7 +930,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Root Folder Note",
folder="", # Empty string instead of None since folder is required
directory="", # Empty string instead of None since folder is required
content="# Root Folder Note\nThis note should be created in the project root.",
)
@@ -955,7 +955,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title=f"Current Dir Test {safe_folder.replace('/', '-').replace('.', 'dot')}",
folder=safe_folder,
directory=safe_folder,
content="# Current Directory Test\nThis should work with current directory references.",
)
@@ -973,7 +973,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Security Test with All Params",
folder="../../../etc/malicious",
directory="../../../etc/malicious",
content="# Malicious Content\nThis should be blocked by security validation.",
tags=["malicious", "test"],
note_type="guide",
@@ -991,7 +991,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Security Logging Test",
folder="../../../etc/passwd_folder",
directory="../../../etc/passwd_folder",
content="# Test Content\nThis should trigger security logging.",
)
@@ -1009,7 +1009,7 @@ class TestWriteNoteSecurityValidation:
result = await write_note.fn(
project=test_project.name,
title="Full Feature Security Test",
folder="security-tests",
directory="security-tests",
content=dedent("""
# Full Feature Security Test
@@ -1064,7 +1064,7 @@ class TestWriteNoteSecurityEdgeCases:
result = await write_note.fn(
project=test_project.name,
title="Unicode Attack Test",
folder=attack_folder,
directory=attack_folder,
content="# Unicode Attack\nThis should be blocked.",
)
@@ -1081,7 +1081,7 @@ class TestWriteNoteSecurityEdgeCases:
result = await write_note.fn(
project=test_project.name,
title="Long Attack Test",
folder=long_attack_folder,
directory=long_attack_folder,
content="# Long Attack\nThis should be blocked.",
)
@@ -1104,7 +1104,7 @@ class TestWriteNoteSecurityEdgeCases:
result = await write_note.fn(
project=test_project.name,
title="Case Variation Attack Test",
folder=attack_folder,
directory=attack_folder,
content="# Case Attack\nThis should be blocked.",
)
@@ -1127,7 +1127,7 @@ class TestWriteNoteSecurityEdgeCases:
result = await write_note.fn(
project=test_project.name,
title="Whitespace Attack Test",
folder=attack_folder,
directory=attack_folder,
content="# Whitespace Attack\nThis should be blocked.",
)
@@ -32,7 +32,7 @@ async def test_write_note_spaces_to_hyphens(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="My Awesome Note",
folder="test",
directory="test",
content="Testing space conversion",
)
@@ -48,7 +48,7 @@ async def test_write_note_underscores_to_hyphens(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="my_note_with_underscores",
folder="test",
directory="test",
content="Testing underscore conversion",
)
@@ -64,7 +64,7 @@ async def test_write_note_camelcase_to_kebab(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="MyAwesomeFeature",
folder="test",
directory="test",
content="Testing CamelCase conversion",
)
@@ -80,7 +80,7 @@ async def test_write_note_mixed_case_to_lowercase(app, test_project, app_config)
result = await write_note.fn(
project=test_project.name,
title="MIXED_Case_Example",
folder="test",
directory="test",
content="Testing case conversion",
)
@@ -105,7 +105,7 @@ async def test_write_note_single_period_preserved(app, test_project, app_config)
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Version",
folder="test",
directory="test",
content="Testing period preservation",
)
@@ -121,7 +121,7 @@ async def test_write_note_multiple_periods_preserved(app, test_project, app_conf
result = await write_note.fn(
project=test_project.name,
title="Version 1.2.3 Release",
folder="test",
directory="test",
content="Testing multiple period preservation",
)
@@ -142,7 +142,7 @@ async def test_write_note_special_chars_to_hyphens(app, test_project, app_config
result = await write_note.fn(
project=test_project.name,
title="Test 2.0: New Feature",
folder="test",
directory="test",
content="Testing special character conversion",
)
@@ -158,7 +158,7 @@ async def test_write_note_parentheses_removed(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="Feature (v2.0) Update",
folder="test",
directory="test",
content="Testing parentheses handling",
)
@@ -174,7 +174,7 @@ async def test_write_note_apostrophes_removed(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="User's Guide",
folder="test",
directory="test",
content="Testing apostrophe handling",
)
@@ -195,7 +195,7 @@ async def test_write_note_all_transformations_combined(app, test_project, app_co
result = await write_note.fn(
project=test_project.name,
title="MyProject_v3.0: Feature Update (DRAFT)",
folder="test",
directory="test",
content="Testing combined transformations",
)
@@ -211,7 +211,7 @@ async def test_write_note_consecutive_special_chars_collapsed(app, test_project,
result = await write_note.fn(
project=test_project.name,
title="Test___Multiple---Separators",
folder="test",
directory="test",
content="Testing consecutive special character collapse",
)
@@ -233,7 +233,7 @@ async def test_write_note_leading_trailing_hyphens_trimmed(app, test_project, ap
result = await write_note.fn(
project=test_project.name,
title="---Test Note---",
folder="test",
directory="test",
content="Testing leading/trailing hyphen trimming",
)
@@ -249,7 +249,7 @@ async def test_write_note_all_special_chars_becomes_valid_filename(app, test_pro
result = await write_note.fn(
project=test_project.name,
title="!!!Test!!!",
folder="test",
directory="test",
content="Testing all special characters",
)
@@ -270,7 +270,7 @@ async def test_write_note_folder_path_unaffected(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="My_Folder/Sub Folder", # Folder should remain as-is
directory="My_Folder/Sub Folder", # Folder should remain as-is
content="Testing folder path preservation",
)
@@ -287,7 +287,7 @@ async def test_write_note_root_folder_with_kebab(app, test_project, app_config):
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Note",
folder="", # Root folder
directory="", # Root folder
content="Testing root folder",
)
@@ -308,7 +308,7 @@ async def test_write_note_kebab_disabled_preserves_original(app, test_project, a
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Version",
folder="test",
directory="test",
content="Testing backward compatibility",
)
@@ -326,7 +326,7 @@ async def test_write_note_kebab_disabled_preserves_underscores(app, test_project
result = await write_note.fn(
project=test_project.name,
title="my_note_example",
folder="test",
directory="test",
content="Testing underscore preservation",
)
@@ -342,7 +342,7 @@ async def test_write_note_kebab_disabled_preserves_case(app, test_project, app_c
result = await write_note.fn(
project=test_project.name,
title="MyAwesomeNote",
folder="test",
directory="test",
content="Testing case preservation",
)
@@ -369,7 +369,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
result1 = await write_note.fn(
project=test_project.name,
title="Test Note 1",
folder="test",
directory="test",
content="Testing permalink consistency",
)
@@ -383,7 +383,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
result2 = await write_note.fn(
project=test_project.name,
title="Test Note 2",
folder="test",
directory="test",
content="Testing permalink consistency",
)
+3 -3
View File
@@ -13,13 +13,13 @@ async def test_search_successful_results(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Document 1",
folder="docs",
directory="docs",
content="# Test Document 1\n\nThis is test content for document 1",
)
await write_note.fn(
project=test_project.name,
title="Test Document 2",
folder="docs",
directory="docs",
content="# Test Document 2\n\nThis is test content for document 2",
)
@@ -72,7 +72,7 @@ async def test_fetch_successful_document(client, test_project):
await write_note.fn(
project=test_project.name,
title="Test Document",
folder="docs",
directory="docs",
content="# Test Document\n\nThis is the content of a test document.",
)
@@ -456,6 +456,66 @@ async def test_get_by_title(entity_repository: EntityRepository, session_maker):
assert len(found) == 2
@pytest.mark.asyncio
async def test_get_by_title_returns_shortest_path_first(
entity_repository: EntityRepository, session_maker
):
"""Test that duplicate titles are returned with shortest path first.
When multiple entities share the same title in different folders,
the one with the shortest file path should be returned first.
This provides consistent, predictable link resolution.
"""
async with db.scoped_session(session_maker) as session:
# Create entities with same title but different path lengths
# Insert in reverse order to ensure we're testing ordering, not insertion order
entities = [
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="archive/old/2024/my-note",
file_path="archive/old/2024/My Note.md", # longest path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="docs/my-note",
file_path="docs/My Note.md", # medium path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="my-note",
file_path="My Note.md", # shortest path (root)
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
]
session.add_all(entities)
await session.flush()
# Get all entities with title "My Note"
found = await entity_repository.get_by_title("My Note")
# Should return all 3
assert len(found) == 3
# Should be ordered by path length (shortest first)
assert found[0].file_path == "My Note.md" # shortest
assert found[1].file_path == "docs/My Note.md" # medium
assert found[2].file_path == "archive/old/2024/My Note.md" # longest
@pytest.mark.asyncio
async def test_get_by_file_path(entity_repository: EntityRepository, session_maker):
"""Test getting an entity by title."""
@@ -136,6 +136,39 @@ async def test_get_default_project(project_repository: ProjectRepository, test_p
assert default_project.is_default is True
@pytest.mark.asyncio
async def test_get_default_project_with_false_values(project_repository: ProjectRepository):
"""Test that get_default_project ignores projects with is_default=False.
Regression test for bug where is_not(None) matched both True and False,
causing MultipleResultsFound when multiple projects had different boolean values.
"""
# Create projects with explicit is_default values
project_true = await project_repository.create({
"name": "Default Project",
"path": "/default/path",
"is_default": True,
})
await project_repository.create({
"name": "Not Default Project",
"path": "/not-default/path",
"is_default": False,
})
await project_repository.create({
"name": "Null Default Project",
"path": "/null/path",
"is_default": None,
})
# Should return only the project with is_default=True
default = await project_repository.get_default_project()
assert default is not None
assert default.id == project_true.id
assert default.name == "Default Project"
@pytest.mark.asyncio
async def test_get_active_projects(project_repository: ProjectRepository):
"""Test getting all active projects."""
+8 -8
View File
@@ -19,7 +19,7 @@ from basic_memory.schemas.base import to_snake_case, TimeFrame, parse_timeframe,
def test_entity_project_name():
"""Test creating EntityIn with minimal required fields."""
data = {"title": "Test Entity", "folder": "test", "entity_type": "knowledge"}
data = {"title": "Test Entity", "directory": "test", "entity_type": "knowledge"}
entity = Entity.model_validate(data)
assert entity.file_path == os.path.join("test", "Test Entity.md")
assert entity.permalink == "test/test-entity"
@@ -28,7 +28,7 @@ def test_entity_project_name():
def test_entity_project_id():
"""Test creating EntityIn with minimal required fields."""
data = {"project": 2, "title": "Test Entity", "folder": "test", "entity_type": "knowledge"}
data = {"project": 2, "title": "Test Entity", "directory": "test", "entity_type": "knowledge"}
entity = Entity.model_validate(data)
assert entity.file_path == os.path.join("test", "Test Entity.md")
assert entity.permalink == "test/test-entity"
@@ -39,7 +39,7 @@ def test_entity_non_markdown():
"""Test entity for regular non-markdown file."""
data = {
"title": "Test Entity.txt",
"folder": "test",
"directory": "test",
"entity_type": "file",
"content_type": "text/plain",
}
@@ -243,11 +243,11 @@ def test_path_sanitization():
def test_permalink_generation():
"""Test permalink property generates correct paths."""
test_cases = [
({"title": "BasicMemory", "folder": "test"}, "test/basic-memory"),
({"title": "Memory Service", "folder": "test"}, "test/memory-service"),
({"title": "API Gateway", "folder": "test"}, "test/api-gateway"),
({"title": "TestCase1", "folder": "test"}, "test/test-case1"),
({"title": "TestCaseRoot", "folder": ""}, "test-case-root"),
({"title": "BasicMemory", "directory": "test"}, "test/basic-memory"),
({"title": "Memory Service", "directory": "test"}, "test/memory-service"),
({"title": "API Gateway", "directory": "test"}, "test/api-gateway"),
({"title": "TestCase1", "directory": "test"}, "test/test-case1"),
({"title": "TestCaseRoot", "directory": ""}, "test-case-root"),
]
for input_data, expected_path in test_cases:
+53 -53
View File
@@ -23,7 +23,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
"""Test successful entity creation."""
entity_data = EntitySchema(
title="Test Entity",
folder="",
directory="",
entity_type="test",
)
@@ -62,7 +62,7 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser
"""Test successful entity creation."""
entity_data = EntitySchema(
title="Test Entity",
folder="",
directory="",
entity_type="test",
content="first",
)
@@ -81,7 +81,7 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser
entity_data = EntitySchema(
title="Test Entity",
folder="",
directory="",
entity_type="test",
content="second",
)
@@ -100,7 +100,7 @@ async def test_create_entity_unique_permalink(
"""Test successful entity creation."""
entity_data = EntitySchema(
title="Test Entity",
folder="test",
directory="test",
entity_type="test",
)
@@ -132,14 +132,14 @@ async def test_get_by_permalink(entity_service: EntityService):
"""Test finding entity by type and name combination."""
entity1_data = EntitySchema(
title="TestEntity1",
folder="test",
directory="test",
entity_type="test",
)
entity1 = await entity_service.create_entity(entity1_data)
entity2_data = EntitySchema(
title="TestEntity2",
folder="test",
directory="test",
entity_type="test",
)
entity2 = await entity_service.create_entity(entity2_data)
@@ -166,7 +166,7 @@ async def test_get_entity_success(entity_service: EntityService):
"""Test successful entity retrieval."""
entity_data = EntitySchema(
title="TestEntity",
folder="test",
directory="test",
entity_type="test",
)
await entity_service.create_entity(entity_data)
@@ -184,7 +184,7 @@ async def test_delete_entity_success(entity_service: EntityService):
"""Test successful entity deletion."""
entity_data = EntitySchema(
title="TestEntity",
folder="test",
directory="test",
entity_type="test",
)
await entity_service.create_entity(entity_data)
@@ -203,7 +203,7 @@ async def test_delete_entity_by_id(entity_service: EntityService):
"""Test successful entity deletion."""
entity_data = EntitySchema(
title="TestEntity",
folder="test",
directory="test",
entity_type="test",
)
created = await entity_service.create_entity(entity_data)
@@ -236,7 +236,7 @@ async def test_create_entity_with_special_chars(entity_service: EntityService):
name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters
entity_data = EntitySchema(
title=name,
folder="test",
directory="test",
entity_type="test",
)
entity = await entity_service.create_entity(entity_data)
@@ -253,12 +253,12 @@ async def test_get_entities_by_permalinks(entity_service: EntityService):
# Create test entities
entity1_data = EntitySchema(
title="Entity1",
folder="test",
directory="test",
entity_type="test",
)
entity2_data = EntitySchema(
title="Entity2",
folder="test",
directory="test",
entity_type="test",
)
await entity_service.create_entity(entity1_data)
@@ -286,7 +286,7 @@ async def test_get_entities_some_not_found(entity_service: EntityService):
# Create one test entity
entity_data = EntitySchema(
title="Entity1",
folder="test",
directory="test",
entity_type="test",
)
await entity_service.create_entity(entity_data)
@@ -317,7 +317,7 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
# Create test entity
schema = EntitySchema(
title="test",
folder="test",
directory="test",
entity_type="note",
entity_metadata={"status": "draft"},
)
@@ -354,7 +354,7 @@ async def test_create_or_update_new(entity_service: EntityService, file_service:
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="test",
folder="test",
directory="test",
entity_type="test",
entity_metadata={"status": "draft"},
)
@@ -370,7 +370,7 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser
entity = await entity_service.create_entity(
EntitySchema(
title="test",
folder="test",
directory="test",
entity_type="test",
content="Test entity",
entity_metadata={"status": "final"},
@@ -413,7 +413,7 @@ async def test_create_with_content(entity_service: EntityService, file_service:
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Git Workflow Guide",
folder="test",
directory="test",
entity_type="test",
content=content,
)
@@ -480,7 +480,7 @@ async def test_update_with_content(entity_service: EntityService, file_service:
EntitySchema(
title="Git Workflow Guide",
entity_type="test",
folder="test",
directory="test",
content=content,
)
)
@@ -538,7 +538,7 @@ async def test_update_with_content(entity_service: EntityService, file_service:
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Git Workflow Guide",
folder="test",
directory="test",
entity_type="test",
content=update_content,
)
@@ -612,7 +612,7 @@ async def test_edit_entity_append(entity_service: EntityService, file_service: F
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -638,7 +638,7 @@ async def test_edit_entity_prepend(entity_service: EntityService, file_service:
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -664,7 +664,7 @@ async def test_edit_entity_find_replace(entity_service: EntityService, file_serv
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="This is old content that needs updating",
)
@@ -704,7 +704,7 @@ async def test_edit_entity_replace_section(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -735,7 +735,7 @@ async def test_edit_entity_replace_section_create_new(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="# Main Title\n\nSome content",
)
@@ -772,7 +772,7 @@ async def test_edit_entity_invalid_operation(entity_service: EntityService):
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -791,7 +791,7 @@ async def test_edit_entity_find_replace_missing_find_text(entity_service: Entity
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -810,7 +810,7 @@ async def test_edit_entity_replace_section_missing_section(entity_service: Entit
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -840,7 +840,7 @@ async def test_edit_entity_with_observations_and_relations(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -949,7 +949,7 @@ async def test_edit_entity_find_replace_not_found(entity_service: EntityService)
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="This is some content",
)
@@ -974,7 +974,7 @@ async def test_edit_entity_find_replace_multiple_occurrences_expected_one(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content="The word banana appears here. Another banana word here.",
)
@@ -1000,7 +1000,7 @@ async def test_edit_entity_find_replace_multiple_occurrences_success(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content="The word banana appears here. Another banana word here.",
)
@@ -1028,7 +1028,7 @@ async def test_edit_entity_find_replace_empty_find_text(entity_service: EntitySe
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Some content",
)
@@ -1063,7 +1063,7 @@ async def test_edit_entity_find_replace_multiline(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1105,7 +1105,7 @@ async def test_edit_entity_replace_section_multiple_sections_error(entity_servic
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1128,7 +1128,7 @@ async def test_edit_entity_replace_section_empty_section(entity_service: EntityS
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Some content",
)
@@ -1163,7 +1163,7 @@ async def test_edit_entity_replace_section_header_variations(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1203,7 +1203,7 @@ async def test_edit_entity_replace_section_at_end_of_document(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1250,7 +1250,7 @@ async def test_edit_entity_replace_section_with_subsections(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1294,7 +1294,7 @@ async def test_edit_entity_replace_section_strips_duplicate_header(
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1336,7 +1336,7 @@ async def test_move_entity_success(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content="Original content",
)
@@ -1385,7 +1385,7 @@ async def test_move_entity_with_permalink_update(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content="Original content",
)
@@ -1426,7 +1426,7 @@ async def test_move_entity_creates_destination_directory(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content="Original content",
)
@@ -1476,7 +1476,7 @@ async def test_move_entity_source_file_missing(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -1508,7 +1508,7 @@ async def test_move_entity_destination_exists(
entity1 = await entity_service.create_entity(
EntitySchema(
title="Test Note 1",
folder="test",
directory="test",
entity_type="note",
content="Content 1",
)
@@ -1517,7 +1517,7 @@ async def test_move_entity_destination_exists(
entity2 = await entity_service.create_entity(
EntitySchema(
title="Test Note 2",
folder="test",
directory="test",
entity_type="note",
content="Content 2",
)
@@ -1545,7 +1545,7 @@ async def test_move_entity_invalid_destination_path(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -1584,7 +1584,7 @@ async def test_move_entity_by_title(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content="Original content",
)
@@ -1629,7 +1629,7 @@ async def test_move_entity_preserves_observations_and_relations(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content=content,
)
@@ -1676,7 +1676,7 @@ async def test_move_entity_rollback_on_database_failure(
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
folder="original",
directory="original",
entity_type="note",
content="Original content",
)
@@ -1736,7 +1736,7 @@ async def test_move_entity_with_complex_observations(
entity = await entity_service.create_entity(
EntitySchema(
title="Complex Note",
folder="docs",
directory="docs",
entity_type="note",
content=content,
)
@@ -1868,7 +1868,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
# Step 1: Create first entity "Node A"
entity_a = EntitySchema(
title="Node A",
folder="edge-cases",
directory="edge-cases",
entity_type="note",
content="# Node A\n\nOriginal content for Node A",
)
@@ -1892,7 +1892,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
# Step 2: Create Node B to match live test scenario
entity_b = EntitySchema(
title="Node B",
folder="edge-cases",
directory="edge-cases",
entity_type="note",
content="# Node B\n\nContent for Node B",
)
@@ -1905,7 +1905,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
# BUG: This will incorrectly match Node A via fuzzy search
entity_c = EntitySchema(
title="Node C",
folder="edge-cases",
directory="edge-cases",
entity_type="note",
content="# Node C\n\nContent for Node C",
)
@@ -34,7 +34,7 @@ async def test_create_entity_with_permalinks_disabled(
entity_data = EntitySchema(
title="Test Entity",
folder="test",
directory="test",
entity_type="note",
content="Test content",
)
@@ -80,7 +80,7 @@ async def test_update_entity_with_permalinks_disabled(
entity_data = EntitySchema(
title="Test Entity",
folder="test",
directory="test",
entity_type="note",
content="Original content",
)
@@ -150,7 +150,7 @@ async def test_create_entity_with_content_frontmatter_permalinks_disabled(
entity_data = EntitySchema(
title="Test Entity",
folder="test",
directory="test",
entity_type="note",
content=content,
)
@@ -196,7 +196,7 @@ async def test_move_entity_with_permalinks_disabled(
entity_data = EntitySchema(
title="Test Entity",
folder="test",
directory="test",
entity_type="note",
content="Test content",
)
+479 -7
View File
@@ -29,7 +29,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Core Service",
entity_type="component",
folder="components",
directory="components",
project=entity_service.repository.project_id,
)
)
@@ -37,7 +37,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Service Config",
entity_type="config",
folder="config",
directory="config",
project=entity_service.repository.project_id,
)
)
@@ -45,7 +45,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Auth Service",
entity_type="component",
folder="components",
directory="components",
project=entity_service.repository.project_id,
)
)
@@ -53,7 +53,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Core Features",
entity_type="specs",
folder="specs",
directory="specs",
project=entity_service.repository.project_id,
)
)
@@ -61,7 +61,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Sub Features 1",
entity_type="specs",
folder="specs/subspec",
directory="specs/subspec",
project=entity_service.repository.project_id,
)
)
@@ -69,7 +69,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Sub Features 2",
entity_type="specs",
folder="specs/subspec",
directory="specs/subspec",
project=entity_service.repository.project_id,
)
)
@@ -92,7 +92,7 @@ async def test_entities(entity_service, file_service):
EntitySchema(
title="Core Service",
entity_type="component",
folder="components2",
directory="components2",
project=entity_service.repository.project_id,
)
)
@@ -357,3 +357,475 @@ async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entit
assert result is not None
# Should return the first match (components/core-service based on test fixture order)
assert result.permalink == "components/core-service"
# ============================================================================
# Context-aware resolution tests (source_path parameter)
# ============================================================================
@pytest_asyncio.fixture
async def context_aware_entities(entity_repository):
"""Create entities for testing context-aware resolution.
Structure:
testing.md (title: "testing", root level)
main/
testing/
testing.md (title: "testing", nested)
another-test.md (title: "another-test")
other/
testing.md (title: "testing", different branch)
deep/
nested/
folder/
note.md (title: "note")
"""
entities = []
now = datetime.now(timezone.utc)
project_id = entity_repository.project_id
# Root level testing.md
e1 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="testing.md",
permalink="testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e1)
# main/testing/testing.md
e2 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="main/testing/testing.md",
permalink="main/testing/testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e2)
# main/testing/another-test.md
e3 = await entity_repository.add(
EntityModel(
title="another-test",
entity_type="note",
content_type="text/markdown",
file_path="main/testing/another-test.md",
permalink="main/testing/another-test",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e3)
# other/testing.md
e4 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="other/testing.md",
permalink="other/testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e4)
# deep/nested/folder/note.md
e5 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="deep/nested/folder/note.md",
permalink="deep/nested/folder/note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e5)
# deep/note.md (for ancestor testing)
e6 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="deep/note.md",
permalink="deep/note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e6)
# note.md at root (for ancestor testing)
e7 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="note.md",
permalink="note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e7)
return entities
@pytest_asyncio.fixture
async def context_link_resolver(entity_repository, search_service, context_aware_entities):
"""Create LinkResolver instance with context-aware test data.
Note: We don't index entities for search because these tests focus on
exact title/permalink matching, not fuzzy search. The entities are
database-only records (no files on disk).
"""
return LinkResolver(entity_repository, search_service)
@pytest.mark.asyncio
async def test_source_path_same_folder_preference(context_link_resolver):
"""Test that links prefer notes in the same folder as the source."""
# From main/testing/another-test.md, [[testing]] should find main/testing/testing.md
result = await context_link_resolver.resolve_link(
"testing",
source_path="main/testing/another-test.md"
)
assert result is not None
assert result.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_source_path_from_root_prefers_root(context_link_resolver):
"""Test that links from root-level notes prefer root-level matches."""
# From root-note.md, [[testing]] should find testing.md (root level)
result = await context_link_resolver.resolve_link(
"testing",
source_path="some-root-note.md"
)
assert result is not None
assert result.file_path == "testing.md"
@pytest.mark.asyncio
async def test_source_path_different_branch_prefers_closest(context_link_resolver):
"""Test resolution when source is in a different branch of the folder tree."""
# From other/testing.md, [[testing]] should find other/testing.md (same folder)
# Wait, other/testing.md IS the testing note in that folder, so this tests self-reference
# Let's test from a hypothetical other/different.md
result = await context_link_resolver.resolve_link(
"testing",
source_path="other/different.md"
)
assert result is not None
# Should find other/testing.md since it's in the same folder
assert result.file_path == "other/testing.md"
@pytest.mark.asyncio
async def test_source_path_ancestor_preference(context_link_resolver):
"""Test that closer ancestors are preferred over distant ones."""
# From deep/nested/folder/note.md, [[note]] with multiple "note" titles
# should prefer the closest ancestor match
# First verify there are multiple "note" entities
# deep/nested/folder/note.md, deep/note.md, note.md
# From deep/nested/folder/some-file.md, [[note]] should prefer:
# 1. deep/nested/folder/note.md (same folder) - but that's the note itself
# Let's say we're linking from a different file in that folder
result = await context_link_resolver.resolve_link(
"note",
source_path="deep/nested/folder/other-file.md"
)
assert result is not None
# Should find deep/nested/folder/note.md (same folder)
assert result.file_path == "deep/nested/folder/note.md"
@pytest.mark.asyncio
async def test_source_path_parent_folder_preference(context_link_resolver):
"""Test that parent folder is preferred when no same-folder match exists."""
# From deep/nested/folder/x.md where there's no "common" in same folder,
# but there's one in deep/nested/ - should prefer closer ancestor
# For this test, let's check that from deep/nested/other/file.md,
# [[note]] finds deep/note.md (ancestor) rather than note.md (root)
result = await context_link_resolver.resolve_link(
"note",
source_path="deep/nested/other/file.md"
)
assert result is not None
# No note.md in deep/nested/other/, so should find deep/note.md (closest ancestor)
# Actually deep/nested/folder/note.md might be considered... let me think
# deep/nested/other/file.md -> ancestors are deep/nested/, deep/, root
# Siblings/cousins like deep/nested/folder/ are NOT ancestors
# So should find deep/note.md
assert result.file_path == "deep/note.md"
@pytest.mark.asyncio
async def test_source_path_no_context_falls_back_to_shortest_path(context_link_resolver):
"""Test that without source_path, resolution falls back to shortest path."""
# Without source_path, should use standard resolution (permalink first, then title)
result = await context_link_resolver.resolve_link("testing")
assert result is not None
# Should get the one with shortest path or matching permalink
# "testing" matches permalink "testing" of root testing.md
assert result.file_path == "testing.md"
@pytest.mark.asyncio
async def test_source_path_unique_title_ignores_context(context_link_resolver):
"""Test that unique titles resolve correctly regardless of source_path."""
# "another-test" only exists in one place
result = await context_link_resolver.resolve_link(
"another-test",
source_path="other/some-file.md" # Different folder
)
assert result is not None
assert result.file_path == "main/testing/another-test.md"
@pytest.mark.asyncio
async def test_source_path_with_permalink_conflict(context_link_resolver):
"""Test that same-folder title match beats permalink match from different folder."""
# Root testing.md has permalink "testing"
# main/testing/testing.md has title "testing"
# From main/testing/another-test.md, [[testing]] should prefer the same-folder match
# even though there's a permalink match at root
result = await context_link_resolver.resolve_link(
"testing",
source_path="main/testing/another-test.md"
)
assert result is not None
# Should prefer same-folder title match over root permalink match
assert result.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_find_closest_entity_same_folder(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity helper with same folder match."""
# Get entities with title "testing"
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
assert len(testing_entities) == 3 # root, main/testing, other
closest = context_link_resolver._find_closest_entity(
testing_entities,
"main/testing/another-test.md"
)
assert closest.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_find_closest_entity_ancestor_preference(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity prefers closer ancestors."""
# Get entities with title "note"
note_entities = [e for e in context_aware_entities if e.title == "note"]
assert len(note_entities) == 3 # deep/nested/folder, deep, root
# From deep/nested/other/file.md, should prefer deep/note.md over note.md
closest = context_link_resolver._find_closest_entity(
note_entities,
"deep/nested/other/file.md"
)
assert closest.file_path == "deep/note.md"
@pytest.mark.asyncio
async def test_find_closest_entity_root_source(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity when source is at root."""
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
# From root level, should prefer root testing.md
closest = context_link_resolver._find_closest_entity(
testing_entities,
"some-root-file.md"
)
assert closest.file_path == "testing.md"
@pytest.mark.asyncio
async def test_nonexistent_link_with_source_path(context_link_resolver):
"""Test that non-existent links return None even with source_path."""
result = await context_link_resolver.resolve_link(
"does-not-exist",
source_path="main/testing/another-test.md"
)
assert result is None
# ============================================================================
# Relative path resolution tests
# ============================================================================
@pytest_asyncio.fixture
async def relative_path_entities(entity_repository):
"""Create entities for testing relative path resolution.
Structure:
testing/
link-test.md (source file for testing)
nested/
deep-note.md (target for relative path)
nested/
deep-note.md (different deep-note at root level)
other/
file.md
"""
entities = []
now = datetime.now(timezone.utc)
project_id = entity_repository.project_id
# testing/link-test.md (source file)
e1 = await entity_repository.add(
EntityModel(
title="link-test",
entity_type="note",
content_type="text/markdown",
file_path="testing/link-test.md",
permalink="testing/link-test",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e1)
# testing/nested/deep-note.md (relative target)
e2 = await entity_repository.add(
EntityModel(
title="deep-note",
entity_type="note",
content_type="text/markdown",
file_path="testing/nested/deep-note.md",
permalink="testing/nested/deep-note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e2)
# nested/deep-note.md (absolute path target)
e3 = await entity_repository.add(
EntityModel(
title="deep-note",
entity_type="note",
content_type="text/markdown",
file_path="nested/deep-note.md",
permalink="nested/deep-note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e3)
# other/file.md
e4 = await entity_repository.add(
EntityModel(
title="file",
entity_type="note",
content_type="text/markdown",
file_path="other/file.md",
permalink="other/file",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e4)
return entities
@pytest_asyncio.fixture
async def relative_path_resolver(entity_repository, search_service, relative_path_entities):
"""Create LinkResolver instance with relative path test data."""
return LinkResolver(entity_repository, search_service)
@pytest.mark.asyncio
async def test_relative_path_resolution_from_subfolder(relative_path_resolver):
"""Test that [[nested/deep-note]] from testing/link-test.md resolves to testing/nested/deep-note.md."""
# From testing/link-test.md, [[nested/deep-note]] should resolve to testing/nested/deep-note.md
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="testing/link-test.md"
)
assert result is not None
assert result.file_path == "testing/nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_falls_back_to_absolute(relative_path_resolver):
"""Test that if relative path doesn't exist, falls back to absolute resolution."""
# From other/file.md, [[nested/deep-note]] should resolve to nested/deep-note.md (absolute)
# because other/nested/deep-note.md doesn't exist
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="other/file.md"
)
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_without_source_uses_absolute(relative_path_resolver):
"""Test that without source_path, paths are resolved as absolute."""
# Without source_path, [[nested/deep-note]] should resolve to nested/deep-note.md
result = await relative_path_resolver.resolve_link("nested/deep-note")
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_from_root_falls_through(relative_path_resolver):
"""Test that paths from root-level files don't try relative resolution."""
# From root-file.md (no folder), [[nested/deep-note]] should resolve to nested/deep-note.md
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="root-file.md"
)
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_simple_link_no_slash_skips_relative_resolution(relative_path_resolver):
"""Test that links without '/' don't trigger relative path resolution."""
# [[deep-note]] should use context-aware title matching, not relative paths
result = await relative_path_resolver.resolve_link(
"deep-note",
source_path="testing/link-test.md"
)
assert result is not None
# Should find testing/nested/deep-note.md via title match with same-folder preference
# Actually both have title "deep-note", so it should prefer the one closer to source
# testing/nested/ is not the same folder as testing/, but it's closer than nested/
# The context-aware resolution will pick the closest match
assert result.file_path == "testing/nested/deep-note.md"
+181
View File
@@ -1348,3 +1348,184 @@ async def test_remove_project_delete_notes_missing_directory(project_service: Pr
project_service.config_manager.remove_project(test_project_name)
except Exception:
pass
@pytest.mark.asyncio
async def test_remove_project_cloud_mode_uses_database_not_config(project_service: ProjectService):
"""Test that in cloud mode, remove_project only checks database for default status.
Regression test for bug where cloud mode checked config file (stale) instead of
database (source of truth) when determining if a project is the default.
"""
test_project_name = f"test-cloud-default-{os.urandom(4).hex()}"
test_project_path = f"/tmp/test-cloud-{os.urandom(8).hex()}"
# Save original cloud_mode setting
config = project_service.config_manager.config
original_cloud_mode = config.cloud_mode
original_default = config.default_project
try:
# Add a test project (not default)
await project_service.add_project(test_project_name, test_project_path, set_default=False)
# Verify project exists and is NOT default in database
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
assert db_project.is_default is not True # Should be None or False
# Simulate stale config: manually set this project as default in config only
# (This simulates what happens when config isn't updated after API calls)
config.default_project = test_project_name
# Enable cloud mode
config.cloud_mode = True
project_service.config_manager.save_config(config)
# In cloud mode, should be able to remove the project because database says it's not default
# (even though stale config says it is) - this should NOT raise ValueError
await project_service.remove_project(test_project_name, delete_notes=False)
# Verify project was removed from database
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
finally:
# Restore original settings
config = project_service.config_manager.config
config.cloud_mode = original_cloud_mode
config.default_project = original_default
project_service.config_manager.save_config(config)
# Cleanup from config if test failed partway
try:
project_service.config_manager.remove_project(test_project_name)
except (ValueError, KeyError):
pass # Project may not be in config
@pytest.mark.asyncio
async def test_remove_project_local_mode_checks_both_config_and_database(
project_service: ProjectService,
):
"""Test that in local mode, remove_project checks both config AND database for default status.
In local mode, we check both sources to be safe - if either says the project is default,
we prevent deletion.
"""
test_project_name = f"test-local-default-{os.urandom(4).hex()}"
test_project_path = f"/tmp/test-local-{os.urandom(8).hex()}"
# Save original settings
config = project_service.config_manager.config
original_cloud_mode = config.cloud_mode
original_default = config.default_project
try:
# Ensure we're in local mode before adding project
config.cloud_mode = False
project_service.config_manager.save_config(config)
# Add a test project (not default) - this will add to both DB and config in local mode
await project_service.add_project(test_project_name, test_project_path, set_default=False)
# Verify project exists and is NOT default in database
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
assert db_project.is_default is not True
# Re-read config to get the updated version (after add_project added the project)
config = project_service.config_manager.config
# Set this project as default in config only (not in DB)
config.default_project = test_project_name
project_service.config_manager.save_config(config)
# In local mode, should NOT be able to remove because config says it's default
with pytest.raises(ValueError, match="Cannot remove the default project"):
await project_service.remove_project(test_project_name, delete_notes=False)
# Verify project still exists in database
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
finally:
# Restore original settings
config = project_service.config_manager.config
config.cloud_mode = original_cloud_mode
config.default_project = original_default
project_service.config_manager.save_config(config)
# Cleanup
try:
project_service.config_manager.remove_project(test_project_name)
except (ValueError, KeyError):
pass
@pytest.mark.asyncio
async def test_remove_project_rejects_database_default_in_both_modes(
project_service: ProjectService,
):
"""Test that remove_project rejects deletion when project is default in database.
This should be blocked in BOTH cloud mode and local mode.
"""
test_project_name = f"test-db-default-{os.urandom(4).hex()}"
test_project_path = f"/tmp/test-db-default-{os.urandom(8).hex()}"
# Save original settings
original_cloud_mode = project_service.config_manager.config.cloud_mode
original_default = project_service.config_manager.config.default_project
try:
# Add a test project and set it as default
await project_service.add_project(test_project_name, test_project_path, set_default=True)
# Verify project is default in database
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
assert db_project.is_default is True
# Test in cloud mode - should reject
config = project_service.config_manager.config
config.cloud_mode = True
project_service.config_manager.save_config(config)
with pytest.raises(ValueError, match="Cannot remove the default project"):
await project_service.remove_project(test_project_name, delete_notes=False)
# Test in local mode - should also reject
config.cloud_mode = False
project_service.config_manager.save_config(config)
with pytest.raises(ValueError, match="Cannot remove the default project"):
await project_service.remove_project(test_project_name, delete_notes=False)
# Verify project still exists in both cases
assert test_project_name in project_service.projects
finally:
# Restore original settings
config = project_service.config_manager.config
config.cloud_mode = original_cloud_mode
config.default_project = original_default
project_service.config_manager.save_config(config)
# Set original default back in database so we can clean up
if original_default:
original_project = await project_service.repository.get_by_name(original_default)
if original_project:
await project_service.repository.set_as_default(original_project.id)
# Cleanup test project
if test_project_name in project_service.projects:
try:
# Clear default in DB first
db_project = await project_service.repository.get_by_name(test_project_name)
if db_project and db_project.is_default:
# Find another project to make default
pass # Let the config_manager handle it
project_service.config_manager.remove_project(test_project_name)
except Exception:
pass
+4 -3
View File
@@ -757,10 +757,11 @@ Testing file timestamps
entity_created_epoch = file_entity.created_at.timestamp()
entity_updated_epoch = file_entity.updated_at.timestamp()
# Allow 2s difference on Windows due to filesystem timing precision
tolerance = 2 if os.name == "nt" else 1
# Allow 2s difference due to filesystem timing precision and database processing delays
# Windows has coarser filesystem timestamps, but Postgres can also have slight timing differences
tolerance = 2
assert abs(entity_created_epoch - file_stats.st_ctime) < tolerance
assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance # Allow tolerance difference
assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance
@pytest.mark.asyncio
+19 -19
View File
@@ -19,7 +19,7 @@ from basic_memory.file_utils import (
parse_frontmatter,
remove_frontmatter,
sanitize_for_filename,
sanitize_for_folder,
sanitize_for_directory,
write_file_atomic,
)
@@ -199,29 +199,29 @@ def test_sanitize_for_filename_removes_invalid_characters():
@pytest.mark.parametrize(
"input_folder,expected",
"input_directory,expected",
[
("", ""), # Empty string
(" ", ""), # Whitespace only
("my-folder", "my-folder"), # Simple folder
("my/folder", "my/folder"), # Nested folder
("my//folder", "my/folder"), # Double slash compressed
("my\\\\folder", "my/folder"), # Windows-style double backslash compressed
("my/folder/", "my/folder"), # Trailing slash removed
("/my/folder", "my/folder"), # Leading slash removed
("./my/folder", "my/folder"), # Leading ./ removed
("my<>folder", "myfolder"), # Special chars removed
("my:folder|test", "myfoldertest"), # More special chars removed
("my_folder-1", "my_folder-1"), # Allowed chars preserved
("my folder", "my folder"), # Space preserved
("my/folder//sub//", "my/folder/sub"), # Multiple compressions and trims
("my\\folder\\sub", "my/folder/sub"), # Windows-style separators normalized
("my/folder<>:|?*sub", "my/foldersub"), # All invalid chars removed
("////my////folder////", "my/folder"), # Excessive leading/trailing/multiple slashes
("my-directory", "my-directory"), # Simple directory
("my/directory", "my/directory"), # Nested directory
("my//directory", "my/directory"), # Double slash compressed
("my\\\\directory", "my/directory"), # Windows-style double backslash compressed
("my/directory/", "my/directory"), # Trailing slash removed
("/my/directory", "my/directory"), # Leading slash removed
("./my/directory", "my/directory"), # Leading ./ removed
("my<>directory", "mydirectory"), # Special chars removed
("my:directory|test", "mydirectorytest"), # More special chars removed
("my_directory-1", "my_directory-1"), # Allowed chars preserved
("my directory", "my directory"), # Space preserved
("my/directory//sub//", "my/directory/sub"), # Multiple compressions and trims
("my\\directory\\sub", "my/directory/sub"), # Windows-style separators normalized
("my/directory<>:|?*sub", "my/directorysub"), # All invalid chars removed
("////my////directory////", "my/directory"), # Excessive leading/trailing/multiple slashes
],
)
def test_sanitize_for_folder_edge_cases(input_folder, expected):
assert sanitize_for_folder(input_folder) == expected
def test_sanitize_for_directory_edge_cases(input_directory, expected):
assert sanitize_for_directory(input_directory) == expected
# =============================================================================