mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb0158c9ea | |||
| 4fd9cae293 |
+52
-60
@@ -1,6 +1,6 @@
|
||||
# Basic Memory Cloud CLI Guide
|
||||
|
||||
The Basic Memory Cloud CLI provides seamless integration between local and cloud knowledge bases using **project-scoped synchronization**. Personal workspaces can optionally use local rclone mirrors, giving you fine-grained control over what syncs and where.
|
||||
The Basic Memory Cloud CLI provides seamless integration between local and cloud knowledge bases using **project-scoped synchronization**. Each project can optionally sync with the cloud, giving you fine-grained control over what syncs and where.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -8,13 +8,9 @@ The cloud CLI enables you to:
|
||||
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
|
||||
- **Project-scoped sync** - Each project independently manages its sync configuration
|
||||
- **Explicit operations** - Sync only what you want, when you want
|
||||
- **Bidirectional sync** - Keep Personal workspace local mirrors and cloud in sync with rclone bisync
|
||||
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
|
||||
- **Offline access** - Work locally, sync when ready
|
||||
|
||||
Team workspaces are accessed through the cloud API/MCP and do not support local
|
||||
multi-user rclone sync/bisync. Use `bm project list --workspace <workspace>` to
|
||||
inspect Team projects.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using Basic Memory Cloud, you need:
|
||||
@@ -43,7 +39,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
**Projects can exist in three states:**
|
||||
|
||||
1. **Cloud-only** - Project exists on cloud, no local copy
|
||||
2. **Cloud + Local (synced)** - Personal workspace project has a local working directory that syncs
|
||||
2. **Cloud + Local (synced)** - Project has a local working directory that syncs
|
||||
3. **Local-only** - Project exists locally and is not routed to cloud
|
||||
|
||||
**Example:**
|
||||
@@ -59,8 +55,8 @@ bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add temp --cloud # No local sync
|
||||
|
||||
# Now you can sync individually (after initial --resync):
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
# temp stays cloud-only
|
||||
```
|
||||
|
||||
@@ -91,7 +87,7 @@ Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% o
|
||||
|
||||
### 2. Set Up Sync
|
||||
|
||||
Install rclone and configure Personal workspace sync credentials:
|
||||
Install rclone and configure credentials:
|
||||
|
||||
```bash
|
||||
bm cloud setup
|
||||
@@ -103,7 +99,7 @@ bm cloud setup
|
||||
3. Generates scoped S3 credentials for sync
|
||||
4. Configures single rclone remote: `basic-memory-cloud`
|
||||
|
||||
**Result:** You're ready to sync Personal workspace projects. No sync directories created yet - those come with project setup.
|
||||
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
|
||||
|
||||
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
|
||||
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
|
||||
@@ -112,7 +108,7 @@ manual install instructions.
|
||||
|
||||
### 3. Add Projects with Sync
|
||||
|
||||
Create Personal workspace projects with optional local sync paths:
|
||||
Create projects with optional local sync paths:
|
||||
|
||||
```bash
|
||||
# Create cloud project without local sync
|
||||
@@ -141,10 +137,10 @@ Establish the initial sync baseline. **Best practice:** Always preview with `--d
|
||||
|
||||
```bash
|
||||
# Step 1: Preview the initial sync (recommended)
|
||||
bm cloud bisync --name research --resync --dry-run
|
||||
bm project bisync --name research --resync --dry-run
|
||||
|
||||
# Step 2: If all looks good, run the actual sync
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
**What happens under the covers:**
|
||||
@@ -171,7 +167,7 @@ This will effectively make both Path1 and Path2 filesystems contain a matching s
|
||||
After the first sync, just run bisync without `--resync`:
|
||||
|
||||
```bash
|
||||
bm cloud bisync --name research
|
||||
bm project bisync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -239,7 +235,7 @@ bm project add research --cloud --local-path ~/Documents/research
|
||||
- Stores sync config in `~/.basic-memory/config.json`
|
||||
- Prepares for bisync (but doesn't sync yet)
|
||||
|
||||
**Result:** Project ready to sync. Run `bm cloud bisync --name research --resync` to establish baseline.
|
||||
**Result:** Project ready to sync. Run `bm project bisync --name research --resync` to establish baseline.
|
||||
|
||||
**Use case 3: Add sync to existing cloud project**
|
||||
|
||||
@@ -296,24 +292,20 @@ For MCP stdio, routing is always local.
|
||||
|
||||
## File Synchronization
|
||||
|
||||
Local rclone sync/bisync is supported only for Personal workspaces. Team
|
||||
workspaces are cloud-only for local CLI usage; access them through cloud API/MCP
|
||||
routing instead of a local multi-user rclone mirror.
|
||||
|
||||
### Understanding the Sync Commands
|
||||
|
||||
**There are three sync-related commands:**
|
||||
|
||||
1. `bm cloud sync` - One-way: local → cloud (make cloud match local)
|
||||
2. `bm cloud bisync` - Two-way: local ↔ cloud (recommended)
|
||||
3. `bm cloud check` - Verify files match (no changes)
|
||||
1. `bm project sync` - One-way: local → cloud (make cloud match local)
|
||||
2. `bm project bisync` - Two-way: local ↔ cloud (recommended)
|
||||
3. `bm project check` - Verify files match (no changes)
|
||||
|
||||
### One-Way Sync: Local → Cloud
|
||||
|
||||
**Use case:** You made changes locally and want to push to cloud (overwrite cloud).
|
||||
|
||||
```bash
|
||||
bm cloud sync --name research
|
||||
bm project sync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -335,10 +327,10 @@ bm cloud sync --name research
|
||||
|
||||
```bash
|
||||
# First time - establish baseline
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
|
||||
# Subsequent syncs
|
||||
bm cloud bisync --name research
|
||||
bm project bisync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -357,7 +349,7 @@ echo "Local change" > ~/Documents/research/notes.md
|
||||
# Cloud now has: "Cloud change"
|
||||
|
||||
# Run bisync
|
||||
bm cloud bisync --name research
|
||||
bm project bisync --name research
|
||||
|
||||
# Result: Newer file wins (based on modification time)
|
||||
# If cloud was more recent, cloud version kept
|
||||
@@ -374,7 +366,7 @@ bm cloud bisync --name research
|
||||
**Use case:** Check if local and cloud match without making changes.
|
||||
|
||||
```bash
|
||||
bm cloud check --name research
|
||||
bm project check --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -386,7 +378,7 @@ bm cloud check --name research
|
||||
|
||||
```bash
|
||||
# One-way check (faster)
|
||||
bm cloud check --name research --one-way
|
||||
bm project check --name research --one-way
|
||||
```
|
||||
|
||||
### Preview Changes (Dry Run)
|
||||
@@ -394,7 +386,7 @@ bm cloud check --name research --one-way
|
||||
**Use case:** See what would change without actually syncing.
|
||||
|
||||
```bash
|
||||
bm cloud bisync --name research --dry-run
|
||||
bm project bisync --name research --dry-run
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -440,20 +432,20 @@ bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add personal --cloud --local-path ~/personal
|
||||
|
||||
# Establish baselines
|
||||
bm cloud bisync --name research --resync
|
||||
bm cloud bisync --name work --resync
|
||||
bm cloud bisync --name personal --resync
|
||||
bm project bisync --name research --resync
|
||||
bm project bisync --name work --resync
|
||||
bm project bisync --name personal --resync
|
||||
|
||||
# Daily workflow: sync everything
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
bm cloud bisync --name personal
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
bm project bisync --name personal
|
||||
```
|
||||
|
||||
**Future:** `--all` flag will sync all configured projects:
|
||||
|
||||
```bash
|
||||
bm cloud bisync --all # Coming soon
|
||||
bm project bisync --all # Coming soon
|
||||
```
|
||||
|
||||
### Mixed Usage
|
||||
@@ -470,8 +462,8 @@ bm project add archive --cloud
|
||||
bm project add temp-notes --cloud
|
||||
|
||||
# Sync only the configured ones
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
|
||||
# Archive and temp-notes stay cloud-only
|
||||
```
|
||||
@@ -669,7 +661,7 @@ code ~/.basic-memory/.bmignore
|
||||
echo "*.tmp" >> ~/.basic-memory/.bmignore
|
||||
|
||||
# Next sync uses updated patterns
|
||||
bm cloud bisync --name research
|
||||
bm project bisync --name research
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -732,7 +724,7 @@ bm cloud login
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -755,7 +747,7 @@ bm cloud bisync --name research --resync
|
||||
echo "# Research Notes" > ~/Documents/research/README.md
|
||||
|
||||
# Now run bisync
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
**Why this happens:** Bisync creates listing files that track the state of each side. When both directories are completely empty, these listing files are considered invalid by rclone.
|
||||
@@ -772,10 +764,10 @@ bm cloud bisync --name research --resync
|
||||
|
||||
```bash
|
||||
# Clear bisync state
|
||||
bm cloud bisync-reset research
|
||||
bm project bisync-reset research
|
||||
|
||||
# Re-establish baseline
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -795,16 +787,16 @@ bm cloud bisync --name research --resync
|
||||
|
||||
```bash
|
||||
# Check what would be deleted
|
||||
bm cloud bisync --name research --dry-run
|
||||
bm project bisync --name research --dry-run
|
||||
|
||||
# If correct, establish new baseline
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
**Solution 2:** Use one-way sync if you know local is correct:
|
||||
|
||||
```bash
|
||||
bm cloud sync --name research
|
||||
bm project sync --name research
|
||||
```
|
||||
|
||||
### Project Not Configured for Sync
|
||||
@@ -817,7 +809,7 @@ bm cloud sync --name research
|
||||
|
||||
```bash
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
bm cloud bisync --name research --resync
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
@@ -889,19 +881,19 @@ bm project set-local <name> # Revert project to local mode
|
||||
|
||||
```bash
|
||||
# One-way sync (local → cloud)
|
||||
bm cloud sync --name <project>
|
||||
bm cloud sync --name <project> --dry-run
|
||||
bm cloud sync --name <project> --verbose
|
||||
bm project sync --name <project>
|
||||
bm project sync --name <project> --dry-run
|
||||
bm project sync --name <project> --verbose
|
||||
|
||||
# Two-way sync (local ↔ cloud) - Recommended
|
||||
bm cloud bisync --name <project> # After first --resync
|
||||
bm cloud bisync --name <project> --resync # First time / force baseline
|
||||
bm cloud bisync --name <project> --dry-run
|
||||
bm cloud bisync --name <project> --verbose
|
||||
bm project bisync --name <project> # After first --resync
|
||||
bm project bisync --name <project> --resync # First time / force baseline
|
||||
bm project bisync --name <project> --dry-run
|
||||
bm project bisync --name <project> --verbose
|
||||
|
||||
# Integrity check
|
||||
bm cloud check --name <project>
|
||||
bm cloud check --name <project> --one-way
|
||||
bm project check --name <project>
|
||||
bm project check --name <project> --one-way
|
||||
|
||||
# List project files by route
|
||||
bm project ls --name <project> # Default target: local
|
||||
@@ -917,9 +909,9 @@ bm project ls --name <project> --cloud --path <subpath>
|
||||
1. **Authenticate cloud access** - `bm cloud login`
|
||||
2. **Install rclone** - `bm cloud setup`
|
||||
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
|
||||
4. **Preview first sync** - `bm cloud bisync --name research --resync --dry-run`
|
||||
5. **Establish baseline** - `bm cloud bisync --name research --resync`
|
||||
6. **Daily workflow** - `bm cloud bisync --name research`
|
||||
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
|
||||
5. **Establish baseline** - `bm project bisync --name research --resync`
|
||||
6. **Daily workflow** - `bm project bisync --name research`
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Each project independently syncs (or doesn't)
|
||||
|
||||
@@ -95,10 +95,7 @@ def sync_project_command(
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
One-way sync: local -> cloud (make cloud identical to local).
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
"""One-way sync: local -> cloud (make cloud identical to local).
|
||||
|
||||
Example:
|
||||
bm cloud sync --name research
|
||||
@@ -146,10 +143,7 @@ def bisync_project_command(
|
||||
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Two-way sync: local <-> cloud (bidirectional sync).
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
"""Two-way sync: local <-> cloud (bidirectional sync).
|
||||
|
||||
Examples:
|
||||
bm cloud bisync --name research --resync # First time
|
||||
@@ -209,10 +203,7 @@ def check_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to check"),
|
||||
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
|
||||
) -> None:
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Verify file integrity between local and cloud.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
"""Verify file integrity between local and cloud.
|
||||
|
||||
Example:
|
||||
bm cloud check --name research
|
||||
@@ -255,10 +246,7 @@ def check_project_command(
|
||||
def bisync_reset(
|
||||
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
|
||||
) -> None:
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Clear bisync state for a project.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
"""Clear bisync state for a project.
|
||||
|
||||
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
|
||||
Useful when bisync gets into an inconsistent state or when remote path changes.
|
||||
@@ -289,10 +277,7 @@ def setup_project_sync(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
local_path: str = typer.Argument(..., help="Local sync directory"),
|
||||
) -> None:
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Configure local sync for an existing cloud project.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
"""Configure local sync for an existing cloud project.
|
||||
|
||||
Example:
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Write note tool for Basic Memory MCP server."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Union, Optional, Literal
|
||||
|
||||
import logfire
|
||||
@@ -269,7 +270,19 @@ async def write_note(
|
||||
raise ValueError(
|
||||
"Entity permalink is required for updates"
|
||||
) # pragma: no cover
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
# Resolve the conflicting entity by file_path with strict=True.
|
||||
# The 409 came from a file_service.exists(file_path) check, so this
|
||||
# file_path is the authoritative key for the canonical row. Resolving
|
||||
# by permalink with fuzzy fallback (the previous behavior) could pick
|
||||
# an orphan with a similar permalink — especially in workspace-prefixed
|
||||
# palaces where the client-built permalink omits the workspace slug —
|
||||
# causing the update to write to the wrong row and the next call to
|
||||
# mint a -1/-2 suffix on the canonical entity.
|
||||
# POSIX-normalize so Windows clients send the same form the server stores.
|
||||
file_path_identifier = Path(entity.file_path).as_posix()
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
file_path_identifier, strict=True
|
||||
)
|
||||
result = await knowledge_client.update_entity(
|
||||
entity_id, entity.model_dump()
|
||||
)
|
||||
|
||||
@@ -5,14 +5,25 @@ Comprehensive tests covering all scenarios including note creation, content form
|
||||
tag handling, error conditions, and edge cases from bug reports.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _json_content(tool_result) -> dict[str, Any]:
|
||||
"""Parse a FastMCP tool result content block into a JSON object."""
|
||||
assert len(tool_result.content) == 1
|
||||
assert tool_result.content[0].type == "text"
|
||||
payload = json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
|
||||
assert isinstance(payload, dict)
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -113,6 +124,83 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_overwrite_resolves_conflict_by_file_path_when_permalink_changes(
|
||||
mcp_server, app, test_project, monkeypatch
|
||||
):
|
||||
"""Overwrite resolves the conflict by strict file path through the MCP client stack."""
|
||||
from basic_memory.mcp.clients import knowledge as knowledge_mod
|
||||
|
||||
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
|
||||
captured_resolve: dict[str, Any] = {}
|
||||
|
||||
async def spy_resolve(self, identifier: str, *, strict: bool = False) -> str:
|
||||
captured_resolve["identifier"] = identifier
|
||||
captured_resolve["strict"] = strict
|
||||
return await original_resolve(self, identifier, strict=strict)
|
||||
|
||||
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
created = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Overwrite Permalink Change",
|
||||
"directory": "overwrite-conflicts",
|
||||
"content": "# Overwrite Permalink Change\n\nOriginal body.",
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
created_payload = _json_content(created)
|
||||
assert created_payload["permalink"] == (
|
||||
f"{test_project.name}/overwrite-conflicts/overwrite-permalink-change"
|
||||
)
|
||||
|
||||
replacement = dedent("""
|
||||
---
|
||||
permalink: overwrite-conflicts/custom-overwrite-permalink
|
||||
---
|
||||
|
||||
# Overwrite Permalink Change
|
||||
|
||||
Replacement body.
|
||||
""").strip()
|
||||
|
||||
updated = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Overwrite Permalink Change",
|
||||
"directory": "overwrite-conflicts",
|
||||
"content": replacement,
|
||||
"overwrite": True,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
updated_payload = _json_content(updated)
|
||||
assert updated_payload["action"] == "updated"
|
||||
assert updated_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
|
||||
assert updated_payload["file_path"] == "overwrite-conflicts/Overwrite Permalink Change.md"
|
||||
assert captured_resolve == {
|
||||
"identifier": "overwrite-conflicts/Overwrite Permalink Change.md",
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
read_updated = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "overwrite-conflicts/custom-overwrite-permalink",
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
read_payload = _json_content(read_updated)
|
||||
assert read_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
|
||||
assert "Replacement body." in read_payload["content"]
|
||||
assert "Original body." not in read_payload["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_tag_array(mcp_server, app, test_project):
|
||||
"""Test creating a note with tag array (Issue #38 regression test)."""
|
||||
|
||||
@@ -12,21 +12,6 @@ from basic_memory.config import ProjectMode
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
["sync", "bisync", "check", "bisync-reset", "sync-setup"],
|
||||
)
|
||||
def test_cloud_sync_command_help_marks_personal_workspace_only(command):
|
||||
"""Cloud sync help should explain that local mirrors are Personal-only."""
|
||||
importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
|
||||
|
||||
result = runner.invoke(app, ["cloud", command, "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Personal workspace local mirror only" in result.output
|
||||
assert "Not supported for Team workspaces" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
|
||||
@@ -1311,3 +1311,76 @@ class TestWriteNoteOverwriteGuard:
|
||||
assert "# Created note" in result
|
||||
assert f"project: {test_project.name}" in result
|
||||
assert "file_path: guard/Brand New Note.md" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_overwrite_resolves_by_file_path_strictly(
|
||||
self, app, test_project, entity_repository, monkeypatch
|
||||
):
|
||||
"""Regression: overwrite=True must resolve the conflicting entity by
|
||||
file_path with strict=True, not by permalink with fuzzy fallback.
|
||||
|
||||
Bug shape: in workspace-prefixed palaces the client-built permalink
|
||||
omits the workspace slug, so resolve_entity(permalink) with the default
|
||||
strict=False would fall through to fuzzy search and could pick an
|
||||
orphan row sharing tokens with the canonical permalink. The update
|
||||
then wrote to the orphan, the canonical row stayed stale, and the
|
||||
next overwrite minted a -1/-2 suffix because the permalink uniqueness
|
||||
check found duplicate rows.
|
||||
|
||||
The 409 we catch came from a file_service.exists(file_path) check,
|
||||
so file_path is the authoritative key — strict resolution against it
|
||||
is safe even when permalinks are workspace-prefixed elsewhere.
|
||||
"""
|
||||
# Spy on the resolve_entity call to assert the identifier and strict flag.
|
||||
from basic_memory.mcp.clients import knowledge as knowledge_mod
|
||||
|
||||
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def spy_resolve(self, identifier, *, strict=False):
|
||||
captured["identifier"] = identifier
|
||||
captured["strict"] = strict
|
||||
return await original_resolve(self, identifier, strict=strict)
|
||||
|
||||
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
|
||||
|
||||
# Create then overwrite the canonical note.
|
||||
await write_note(
|
||||
project=test_project.name,
|
||||
title="Overview",
|
||||
directory="features/foo",
|
||||
content="# Overview\n\nVersion A",
|
||||
)
|
||||
canonical_permalink = f"{test_project.name}/features/foo/overview"
|
||||
canonical = await entity_repository.get_by_permalink(canonical_permalink)
|
||||
assert canonical is not None
|
||||
canonical_id = canonical.id
|
||||
|
||||
result = await write_note(
|
||||
project=test_project.name,
|
||||
title="Overview",
|
||||
directory="features/foo",
|
||||
content="# Overview\n\nVersion B",
|
||||
overwrite=True,
|
||||
)
|
||||
assert "# Updated note" in result
|
||||
|
||||
# The overwrite path resolved by file_path with strict=True — not by
|
||||
# permalink with the default fuzzy fallback.
|
||||
assert captured.get("identifier") == "features/foo/Overview.md"
|
||||
assert captured.get("strict") is True
|
||||
|
||||
# And the canonical row was updated in place — no duplicate -1/-2 row.
|
||||
canonical_after = await entity_repository.get_by_permalink(canonical_permalink)
|
||||
assert canonical_after is not None
|
||||
assert canonical_after.id == canonical_id
|
||||
|
||||
content = await read_note(canonical_permalink, project=test_project.name)
|
||||
assert "Version B" in content
|
||||
assert "Version A" not in content
|
||||
|
||||
for suffix in ("-1", "-2"):
|
||||
stray = await entity_repository.get_by_permalink(f"{canonical_permalink}{suffix}")
|
||||
assert stray is None, (
|
||||
f"overwrite=True minted a stray '{suffix}' suffix on the canonical permalink"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user