Compare commits

...

14 Commits

Author SHA1 Message Date
phernandez 7ebf16a95d chore: update version to 0.17.8 for v0.17.8 release 2026-01-24 11:43:44 -06:00
phernandez c05075f8d4 docs: add CHANGELOG entry for v0.17.8
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-24 11:43:21 -06:00
phernandez 4cef9281ca docs: add CHANGELOG entry for v0.17.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-24 11:39:41 -06:00
Paul Hernandez 6888effef2 fix: correct get_default_project() query to check for True instead of not NULL (#521)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:37:05 -06:00
Paul Hernandez 38616c345d fix: read default project from database in cloud mode (#520)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:44:04 -06:00
phernandez f3c1aa895c fix links in README.md to remove smithery badge
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:58:03 -06:00
phernandez d978aba09b fix links in README.md to remove glama.ai
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:56:33 -06:00
phernandez 2aaee734c9 fix links in README.md to point to basicmemory.com instead of basicmachines.co
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-22 12:53:18 -06:00
Paul Hernandez 369ad37b3d feat: add SPEC-29 Phase 3 bucket snapshot CLI commands (#476)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:55:01 -06:00
Drew Cain 4e5f701d22 Fix server.json runtimeArguments format
- Use proper Argument object format instead of plain strings
- Add .mcpregistry tokens to .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-01-19 12:23:29 -06:00
Drew Cain 9d9ea4d61c chore: update version to 0.17.7 for v0.17.7 release 2026-01-19 12:12:35 -06:00
Drew Cain 7a502e6474 feat: Add MCP registry publication files (#515)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:57:50 -06:00
Paul Hernandez c7835a9d5c fix: ensure external_id is set on entity creation (#512) (#513)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:14:48 -06:00
Paul Hernandez 85835ae533 chore: Remove OpenPanel telemetry (#514)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:24:27 -06:00
47 changed files with 1880 additions and 843 deletions
+31 -1
View File
@@ -78,6 +78,34 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) then:
2. Verify formula version matches release
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
#### MCP Registry Publication
After PyPI release is published, update the MCP registry:
1. **Verify PyPI Release**
- Confirm package is live: https://pypi.org/project/basic-memory/<version>/
- The `server.json` version was auto-updated by `just release`
2. **Publish to MCP Registry**
```bash
cd /Users/drew/code/basic-memory
mcp-publisher publish
```
If not authenticated:
```bash
mcp-publisher login github
# Follow device authentication flow
mcp-publisher publish
```
3. **Verify Publication**
```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=basic-memory"
```
**Note:** The `mcp-publisher` CLI can be installed via Homebrew (`brew install mcp-publisher`) or from GitHub releases.
#### Website Updates
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
@@ -145,6 +173,7 @@ Before starting, verify:
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
🔌 MCP Registry: https://registry.modelcontextprotocol.io
🚀 GitHub Actions: Completed
Install with pip/uv:
@@ -162,8 +191,9 @@ Users can now upgrade:
- This creates production releases used by end users
- Must pass all quality gates before proceeding
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Version is automatically updated in `__init__.py` and `server.json`
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- MCP Registry is updated manually via `mcp-publisher publish`
- Supports multiple installation methods (uv, pip, Homebrew)
+1
View File
@@ -54,3 +54,4 @@ ENV/
claude-output
**/.claude/settings.local.json
.mcp.json
.mcpregistry_*
+37
View File
@@ -1,5 +1,42 @@
# CHANGELOG
## 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
+2
View File
@@ -240,6 +240,8 @@ 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
+5 -56
View File
@@ -1,3 +1,4 @@
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![PyPI version](https://badge.fury.io/py/basic-memory.svg)](https://badge.fury.io/py/basic-memory)
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
@@ -5,7 +6,6 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
![](https://badge.mcpx.dev?type=server 'MCP Server')
![](https://badge.mcpx.dev?type=dev 'MCP Dev')
[![smithery badge](https://smithery.ai/badge/@basicmachines-co/basic-memory)](https://smithery.ai/server/@basicmachines-co/basic-memory)
## 🚀 Basic Memory Cloud is Live!
@@ -13,7 +13,7 @@
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
[Sign up now →](https://basicmemory.com/beta)
[Sign up now →](https://basicmemory.com)
with a 7 day free trial
@@ -23,8 +23,8 @@ Basic Memory lets you build persistent knowledge through natural conversations w
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
enable any compatible LLM to read and write to your local knowledge base.
- Website: https://basicmachines.co
- Documentation: https://memory.basicmachines.co
- Website: https://basicmemory.com
- Documentation: https://docs.basicmemory.com
## Pick up your conversation right where you left off
@@ -62,24 +62,6 @@ uv tool install basic-memory
You can view shared context via files in `~/basic-memory` (default directory location).
### Alternative Installation via Smithery
You can use [Smithery](https://smithery.ai/server/@basicmachines-co/basic-memory) to automatically configure Basic
Memory for Claude Desktop:
```bash
npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
```
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. The
Smithery server hosts the MCP server component, while your data remains stored locally as Markdown files.
### Glama.ai
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
<img width="380" height="200" src="https://glama.ai/mcp/servers/o90kttu9ym/badge" alt="basic-memory MCP server" />
</a>
## Why Basic Memory?
Most LLM interactions are ephemeral - you ask a question, get an answer, and everything is forgotten. Each conversation
@@ -425,7 +407,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/)
@@ -466,39 +448,6 @@ tail -f ~/.basic-memory/basic-memory.log
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
```
## Telemetry
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
**What we collect:**
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized - no file paths or personal data)
**What we NEVER collect:**
- Note content, file names, or paths
- Personal information
- IP addresses
**Opting out:**
```bash
# Disable telemetry
basic-memory telemetry disable
# Check status
basic-memory telemetry status
# Re-enable
basic-memory telemetry enable
```
Or set the environment variable:
```bash
export BASIC_MEMORY_TELEMETRY_ENABLED=false
```
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
## Development
### Running Tests
+1 -3
View File
@@ -8,9 +8,8 @@ To keep the default CI signal **stable and meaningful**, the default `pytest` co
- highly environment-dependent (OS/DB tuning)
- inherently interactive (CLI)
- background-task orchestration (watchers/sync runners)
- external analytics
### Whats excluded (and why)
### What's excluded (and why)
Coverage excludes are configured in `pyproject.toml` under `[tool.coverage.report].omit`.
@@ -19,7 +18,6 @@ Current exclusions include:
- `src/basic_memory/db.py`: platform/backend tuning paths (SQLite/Postgres/Windows), covered by integration tests and targeted runs.
- `src/basic_memory/services/initialization.py`: startup orchestration/background tasks; covered indirectly by app/MCP entrypoints.
- `src/basic_memory/sync/sync_service.py`: heavy filesystem↔DB integration; validated in integration suite (not enforced in unit coverage).
- `src/basic_memory/telemetry.py`: external analytics; exercised lightly but excluded from strict coverage gate.
### Recommended additional runs
+16 -5
View File
@@ -204,9 +204,14 @@ release version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} release"
# Create and push tag
@@ -221,9 +226,10 @@ release version:
echo "📦 GitHub Actions will build and publish to PyPI"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo ""
echo "📝 REMINDER: Update documentation sites after release is published:"
echo "📝 REMINDER: Post-release tasks:"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " 3. MCP Registry - Run: mcp-publisher publish"
echo " See: .claude/commands/release/release.md for detailed instructions"
# Create a beta release (e.g., just beta v0.13.2b1)
@@ -269,9 +275,14 @@ beta version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} beta release"
# Create and push tag
-2
View File
@@ -41,7 +41,6 @@ dependencies = [
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
"sniffio>=1.3.1",
"anyio>=4.10.0",
"httpx>=0.28.0",
@@ -143,6 +142,5 @@ omit = [
"*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
"*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
"*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
"*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
"*/services/migration_service.py", # Complex migration scenarios
]
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.basicmachines-co/basic-memory",
"description": "Local-first knowledge management with bi-directional LLM sync via Markdown files.",
"repository": {
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.17.8",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.17.8",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
{"type": "positional", "value": "mcp"}
],
"transport": {
"type": "stdio"
}
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.17.6"
__version__ = "0.17.8"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+15 -3
View File
@@ -322,7 +322,14 @@ async def remove_project(
) # pragma: no cover
# Check if trying to delete the default project
if name == project_service.default_project:
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_project_name = db_default.name if db_default else config_default
if name == default_project_name:
available_projects = await project_service.list_projects()
other_projects = [p.name for p in available_projects if p.name != name]
detail = f"Cannot delete default project '{name}'. "
@@ -418,8 +425,13 @@ async def get_default_project(
Returns:
Response with project default information
"""
# Get the old default project
default_name = project_service.default_project
# Get the default project
# In cloud mode, database is source of truth; in local mode, check config
config_default = project_service.default_project
db_default = await project_service.repository.get_default_project()
# Use database default if available, otherwise fall back to config default
default_name = db_default.name if db_default else config_default
default_project = await project_service.get_project(default_name)
if not default_project: # pragma: no cover
raise HTTPException( # pragma: no cover
@@ -1,6 +1,7 @@
"""Routes for getting entity content."""
import tempfile
import uuid
from pathlib import Path
from typing import Annotated, Union
@@ -218,7 +219,9 @@ async def write_resource(
status_code = 200
else:
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
@@ -9,6 +9,7 @@ Key differences from v1:
- More RESTful: POST for create, PUT for update, GET for read
"""
import uuid
from pathlib import Path as PathLib
from fastapi import APIRouter, HTTPException, Response, Path
@@ -147,7 +148,9 @@ async def create_resource(
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
-8
View File
@@ -10,7 +10,6 @@ import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
def version_callback(value: bool) -> None:
@@ -47,13 +46,6 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Show telemetry notice and track CLI startup
# Skip for 'mcp' command - it handles its own telemetry in lifespan
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
show_notice_if_needed()
track_app_started("cli")
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
+1 -2
View File
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory."""
from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
from . import import_claude_projects, import_chatgpt, tool, project, format
__all__ = [
"status",
@@ -14,5 +14,4 @@ __all__ = [
"tool",
"project",
"format",
"telemetry",
]
@@ -1,6 +1,16 @@
"""Cloud commands package."""
from basic_memory.cli.app import cloud_app
# Import all commands to register them with typer
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
# Register snapshot sub-command group
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
cloud_app.add_typer(snapshot_app, name="snapshot")
# Register restore command (directly on cloud_app via decorator)
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
@@ -0,0 +1,159 @@
"""Restore CLI commands for Basic Memory Cloud.
SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots.
"""
import asyncio
import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
make_api_request,
)
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
from basic_memory.config import ConfigManager
console = Console()
@cloud_app.command("restore")
def restore(
path: str = typer.Argument(
...,
help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')",
),
snapshot_id: str = typer.Option(
...,
"--snapshot",
"-s",
help="ID of the snapshot to restore from",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation prompt",
),
) -> None:
"""Restore a file or folder from a snapshot.
This command restores files from a previous snapshot to the current bucket.
The restored files will overwrite any existing files at the same path.
Examples:
bm cloud restore notes/project.md --snapshot abc123
bm cloud restore research/ --snapshot abc123
bm cloud restore notes/project.md --snapshot abc123 --force
"""
async def _restore():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
# Normalize path - remove leading slash if present
normalized_path = path.lstrip("/")
if not force:
# Show what will be restored
console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]")
console.print(f" Path: {normalized_path}")
# Try to browse the snapshot to show what files will be affected
try:
browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
if normalized_path:
browse_url += f"?prefix={normalized_path}"
response = await make_api_request(
method="GET",
url=browse_url,
)
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
if browse_response.files:
if len(browse_response.files) <= 10:
console.print("\n Files to restore:")
for file_info in browse_response.files:
console.print(f" - {file_info.key}")
else:
console.print(
f"\n {len(browse_response.files)} files will be restored"
)
console.print(" First 5 files:")
for file_info in browse_response.files[:5]:
console.print(f" - {file_info.key}")
console.print(f" ... and {len(browse_response.files) - 5} more")
else:
console.print(
f"\n[yellow]No files found matching '{normalized_path}' "
f"in snapshot[/yellow]"
)
raise typer.Exit(0)
except CloudAPIError as browse_error:
if browse_error.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
raise typer.Exit(1)
# If browse fails for other reasons, proceed with confirmation anyway
pass
console.print(
"\n[yellow]Warning: Restored files will overwrite existing files![/yellow]"
)
confirmed = typer.confirm("\nProceed with restore?")
if not confirmed:
console.print("[yellow]Restore cancelled[/yellow]")
raise typer.Exit(0)
console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore",
json_data={"path": normalized_path},
)
data = response.json()
restored_files = data.get("restored", [])
returned_snapshot_id = data.get("snapshot_id", snapshot_id)
if restored_files:
console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]")
if len(restored_files) <= 10:
for file_path in restored_files:
console.print(f" - {file_path}")
else:
console.print(" First 5 restored files:")
for file_path in restored_files[:5]:
console.print(f" - {file_path}")
console.print(f" ... and {len(restored_files) - 5} more")
console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]")
else:
console.print("[yellow]No files were restored[/yellow]")
console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]")
except typer.Exit:
# Re-raise typer.Exit without modification - it's used for clean exits
raise
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to restore: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_restore())
@@ -0,0 +1,55 @@
"""Pydantic schemas for Basic Memory Cloud API responses.
These schemas mirror the API response models from basic-memory-cloud
for type-safe parsing of API responses in CLI commands.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class BucketSnapshotFileInfo(BaseModel):
"""File info from snapshot browse response."""
key: str
size: int
last_modified: datetime
etag: str | None = None
class BucketSnapshotBrowseResponse(BaseModel):
"""Response from browsing snapshot contents."""
files: list[BucketSnapshotFileInfo]
prefix: str
snapshot_version: str
class BucketSnapshotResponse(BaseModel):
"""Response model for bucket snapshot data."""
id: UUID
bucket_name: str
snapshot_version: str
name: str
description: str | None
auto: bool
created_at: datetime
created_by: UUID | None = None
class BucketSnapshotListResponse(BaseModel):
"""Response from listing bucket snapshots."""
snapshots: list[BucketSnapshotResponse]
total: int
class BucketSnapshotRestoreResponse(BaseModel):
"""Response from restore operation."""
restored: list[str]
snapshot_version: str
snapshot_id: UUID
@@ -0,0 +1,370 @@
"""Snapshot CLI commands for Basic Memory Cloud.
SPEC-29 Phase 3: CLI commands for managing Tigris bucket snapshots.
"""
import asyncio
from datetime import datetime
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
make_api_request,
)
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
from basic_memory.config import ConfigManager
console = Console()
snapshot_app = typer.Typer(help="Manage bucket snapshots")
def _format_timestamp(iso_timestamp: str) -> str:
"""Format ISO timestamp to a human-readable format."""
try:
dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError):
return iso_timestamp
@snapshot_app.command("create")
def create(
description: str = typer.Argument(
...,
help="Description for the snapshot",
),
) -> None:
"""Create a new bucket snapshot.
Examples:
bm cloud snapshot create "before major refactor"
bm cloud snapshot create "daily backup"
"""
async def _create():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
console.print("[blue]Creating snapshot...[/blue]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/bucket-snapshots",
json_data={"description": description},
)
data = response.json()
snapshot_id = data.get("id", "unknown")
snapshot_version = data.get("snapshot_version", "unknown")
created_at = _format_timestamp(data.get("created_at", ""))
console.print("[green]Snapshot created successfully[/green]")
console.print(f" ID: {snapshot_id}")
console.print(f" Version: {snapshot_version}")
console.print(f" Created: {created_at}")
console.print(f" Description: {description}")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
console.print(f"[red]Failed to create snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_create())
@snapshot_app.command("list")
def list_snapshots(
limit: int = typer.Option(
10,
"--limit",
"-l",
help="Maximum number of snapshots to display",
),
) -> None:
"""List all bucket snapshots.
Examples:
bm cloud snapshot list
bm cloud snapshot list --limit 20
"""
async def _list():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
console.print("[blue]Fetching snapshots...[/blue]")
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots",
)
data = response.json()
snapshots = data.get("snapshots", [])
total = data.get("total", len(snapshots))
if not snapshots:
console.print("[yellow]No snapshots found[/yellow]")
console.print(
'\n[dim]Create a snapshot with: bm cloud snapshot create "description"[/dim]'
)
return
# Create a table for displaying snapshots
table = Table(title=f"Bucket Snapshots ({total} total)")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Description", style="white")
table.add_column("Auto", style="dim")
table.add_column("Created", style="green")
for snapshot in snapshots[:limit]:
snapshot_id = snapshot.get("id", "unknown")
desc = snapshot.get("description") or snapshot.get("name", "-")
auto = "yes" if snapshot.get("auto", False) else "no"
created_at = _format_timestamp(snapshot.get("created_at", ""))
table.add_row(snapshot_id, desc, auto, created_at)
console.print(table)
if total > limit:
console.print(
f"\n[dim]Showing {limit} of {total} snapshots. Use --limit to see more.[/dim]"
)
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
console.print(f"[red]Failed to list snapshots: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_list())
@snapshot_app.command("delete")
def delete(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to delete",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation prompt",
),
) -> None:
"""Delete a bucket snapshot.
Examples:
bm cloud snapshot delete abc123
bm cloud snapshot delete abc123 --force
"""
async def _delete():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
if not force:
# Fetch snapshot details first to show what will be deleted
console.print("[blue]Fetching snapshot details...[/blue]")
try:
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
data = response.json()
desc = data.get("description") or data.get("name", "unnamed")
created_at = _format_timestamp(data.get("created_at", ""))
console.print("\nSnapshot to delete:")
console.print(f" ID: {snapshot_id}")
console.print(f" Description: {desc}")
console.print(f" Created: {created_at}")
except CloudAPIError:
# If we can't fetch details, proceed with confirmation anyway
pass
confirmed = typer.confirm("\nAre you sure you want to delete this snapshot?")
if not confirmed:
console.print("[yellow]Deletion cancelled[/yellow]")
raise typer.Exit(0)
console.print("[blue]Deleting snapshot...[/blue]")
await make_api_request(
method="DELETE",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
console.print(f"[green]Snapshot {snapshot_id} deleted successfully[/green]")
except typer.Exit:
# Re-raise typer.Exit without modification - it's used for clean exits
raise
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to delete snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_delete())
@snapshot_app.command("show")
def show(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to show",
),
) -> None:
"""Show details of a specific snapshot.
Examples:
bm cloud snapshot show abc123
"""
async def _show():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(
method="GET",
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
)
data = response.json()
console.print("[bold blue]Snapshot Details[/bold blue]")
console.print(f" ID: {data.get('id', 'unknown')}")
console.print(f" Bucket: {data.get('bucket_name', 'unknown')}")
console.print(f" Version: {data.get('snapshot_version', 'unknown')}")
console.print(f" Name: {data.get('name', '-')}")
console.print(f" Description: {data.get('description') or '-'}")
console.print(f" Auto: {'yes' if data.get('auto', False) else 'no'}")
console.print(f" Created: {_format_timestamp(data.get('created_at', ''))}")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to get snapshot details: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_show())
@snapshot_app.command("browse")
def browse(
snapshot_id: str = typer.Argument(
...,
help="The ID of the snapshot to browse",
),
prefix: Optional[str] = typer.Option(
None,
"--prefix",
"-p",
help="Filter files by path prefix (e.g., 'notes/')",
),
) -> None:
"""Browse contents of a snapshot.
Examples:
bm cloud snapshot browse abc123
bm cloud snapshot browse abc123 --prefix notes/
"""
async def _browse():
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
if prefix:
url += f"?prefix={prefix}"
response = await make_api_request(
method="GET",
url=url,
)
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
if not browse_response.files:
if prefix:
console.print(f"[yellow]No files found with prefix '{prefix}'[/yellow]")
else:
console.print("[yellow]No files found in snapshot[/yellow]")
return
console.print(
f"[bold blue]Snapshot Contents ({len(browse_response.files)} files)[/bold blue]"
)
for file_info in browse_response.files:
size_kb = file_info.size // 1024
console.print(f" {file_info.key} ({size_kb} KB)")
console.print(
f"\n[dim]Use 'bm cloud restore <path> --snapshot {snapshot_id}' "
f"to restore files[/dim]"
)
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
raise typer.Exit(1)
except CloudAPIError as e:
if e.status_code == 404:
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
else:
console.print(f"[red]Failed to browse snapshot: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
asyncio.run(_browse())
@@ -14,7 +14,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.telemetry import shutdown_telemetry
console = Console()
@@ -24,8 +23,8 @@ T = TypeVar("T")
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine with proper database cleanup.
This helper ensures database connections and telemetry threads are cleaned up
before the event loop closes, preventing process hangs in CLI commands.
This helper ensures database connections are cleaned up before the
event loop closes, preventing process hangs in CLI commands.
Args:
coro: The coroutine to run
@@ -39,9 +38,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
return await coro
finally:
await db.shutdown_db()
# Shutdown telemetry to stop the OpenPanel background thread
# This prevents hangs on Python 3.14+ during thread shutdown
shutdown_telemetry()
return asyncio.run(_with_cleanup())
@@ -1,81 +0,0 @@
"""Telemetry commands for basic-memory CLI."""
import typer
from rich.console import Console
from rich.panel import Panel
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager
console = Console()
# Create telemetry subcommand group
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
app.add_typer(telemetry_app, name="telemetry")
@telemetry_app.command("enable")
def enable() -> None:
"""Enable anonymous telemetry.
Telemetry helps improve Basic Memory by collecting anonymous usage data.
No personal data, note content, or file paths are ever collected.
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = True
config_manager.save_config(config)
console.print("[green]Telemetry enabled[/green]")
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
@telemetry_app.command("disable")
def disable() -> None:
"""Disable anonymous telemetry.
You can re-enable telemetry anytime with: bm telemetry enable
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = False
config_manager.save_config(config)
console.print("[yellow]Telemetry disabled[/yellow]")
@telemetry_app.command("status")
def status() -> None:
"""Show current telemetry status and what's collected."""
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
config = ConfigManager().config
status_text = (
"[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
)
console.print(f"\nTelemetry: {status_text}")
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
console.print()
what_we_collect = """
[bold]What we collect:[/bold]
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands)
- Sync statistics (entity count, duration)
- Error types (sanitized, no file paths)
[bold]What we NEVER collect:[/bold]
- Note content, file names, or paths
- Personal information
- IP addresses
"""
console.print(
Panel(
what_we_collect.strip(),
title="Telemetry Details",
border_style="blue",
expand=False,
)
)
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
-1
View File
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
telemetry,
tool,
)
+1 -12
View File
@@ -221,17 +221,6 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
# Telemetry configuration (Homebrew-style opt-out)
telemetry_enabled: bool = Field(
default=True,
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
)
telemetry_notice_shown: bool = Field(
default=False,
description="Whether the one-time telemetry notice has been shown to the user",
)
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -241,7 +230,7 @@ class BasicMemoryConfig(BaseSettings):
- BASIC_MEMORY_ENV environment variable is "test"
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
Used to disable features like telemetry and file watchers during tests.
Used to disable features like file watchers during tests.
"""
return (
self.env == "test"
+7
View File
@@ -1,5 +1,6 @@
"""Utilities for converting between markdown and entity models."""
import uuid
from pathlib import Path
from typing import Any, Optional
@@ -40,6 +41,12 @@ def entity_model_from_markdown(
# Create or update entity
model = entity or Entity()
# Ensure external_id is set for new entities
# SQLAlchemy's Python-side default may not always evaluate,
# so we explicitly set it here for reliability (fixes #512)
if not model.external_id:
model.external_id = str(uuid.uuid4())
# Update basic fields
model.title = markdown.frontmatter.title
model.entity_type = markdown.frontmatter.type
-6
View File
@@ -10,7 +10,6 @@ from loguru import logger
from basic_memory import db
from basic_memory.mcp.container import McpContainer, set_container
from basic_memory.services.initialization import initialize_app
from basic_memory.telemetry import show_notice_if_needed, track_app_started
@asynccontextmanager
@@ -19,7 +18,6 @@ async def lifespan(app: FastMCP):
Handles:
- Database initialization and migrations
- Telemetry notice and tracking
- File sync via SyncCoordinator (if enabled and not in cloud mode)
- Proper cleanup on shutdown
"""
@@ -30,10 +28,6 @@ async def lifespan(app: FastMCP):
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
# Show telemetry notice (first run only) and track startup
show_notice_if_needed()
track_app_started("mcp")
# Track if we created the engine (vs test fixtures providing it)
# This prevents disposing an engine provided by test fixtures when
# multiple Client connections are made in the same test
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -87,7 +86,6 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
track_mcp_tool("build_context")
logger.info(f"Building context from {url} in project {project}")
# Convert string depth to integer if needed
-2
View File
@@ -13,7 +13,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -95,7 +94,6 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or folder path is invalid
"""
track_mcp_tool("canvas")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -15,7 +15,6 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.schemas.search import SearchResponse
from basic_memory.config import ConfigManager
from basic_memory.telemetry import track_mcp_tool
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
@@ -89,7 +88,6 @@ async def search(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body contains `results`, `total_count`, and echo of `query`.
"""
track_mcp_tool("search")
logger.info(f"ChatGPT search request: query='{query}'")
try:
@@ -153,7 +151,6 @@ async def fetch(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
"""
track_mcp_tool("fetch")
logger.info(f"ChatGPT fetch request: id='{id}'")
try:
@@ -8,7 +8,6 @@ from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.telemetry import track_mcp_tool
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -202,7 +201,6 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
track_mcp_tool("delete_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
def _format_error_response(
@@ -213,7 +212,6 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
track_mcp_tool("edit_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -63,7 +62,6 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
track_mcp_tool("list_directory")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
-2
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -400,7 +399,6 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
track_mcp_tool("move_note")
async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
@@ -10,7 +10,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import generate_permalink
@@ -36,7 +35,6 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example:
list_memory_projects()
"""
track_mcp_tool("list_memory_projects")
async with get_client() as client:
if context: # pragma: no cover
await context.info("Listing all available projects")
@@ -92,7 +90,6 @@ async def create_memory_project(
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
track_mcp_tool("create_memory_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -151,7 +148,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
track_mcp_tool("delete_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -20,7 +20,6 @@ from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -201,7 +200,6 @@ async def read_content(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal
"""
track_mcp_tool("read_content")
logger.info("Reading file", path=path, project=project)
async with get_client() as client:
-2
View File
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -77,7 +76,6 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
track_mcp_tool("read_note")
async with get_client() as client:
# Get and validate the project
active_project = await get_active_project(client, project, context)
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -100,7 +99,6 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
track_mcp_tool("recent_activity")
async with get_client() as client:
# Build common parameters for API calls
params = {
-2
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -330,7 +329,6 @@ async def search_notes(
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
track_mcp_tool("search_notes")
# Avoid mutable-default-argument footguns. Treat None as "no filter".
types = types or []
entity_types = entity_types or []
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -55,7 +54,6 @@ async def view_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal
"""
track_mcp_tool("view_note")
logger.info(f"Viewing note: {identifier} in project: {project}")
# Call the existing read_note logic
-2
View File
@@ -7,7 +7,6 @@ from loguru import logger
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from fastmcp import Context
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags, validate_project_path
@@ -115,7 +114,6 @@ async def write_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If folder path attempts path traversal
"""
track_mcp_tool("write_note")
async with get_client() as client:
logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
@@ -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]:
-320
View File
@@ -1,320 +0,0 @@
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
This module implements privacy-respecting usage analytics following the Homebrew model:
- Telemetry is ON by default
- Users can easily opt out: `bm telemetry disable`
- First run shows a one-time notice (not a prompt)
- Only anonymous data is collected (random UUID, no personal info)
What we collect:
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized, no file paths or personal data)
What we NEVER collect:
- Note content, file names, or paths
- Personal information
- IP addresses (OpenPanel doesn't store these)
Documentation: https://basicmemory.com/telemetry
"""
from __future__ import annotations
import platform
import re
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from basic_memory import __version__
if TYPE_CHECKING:
from openpanel import OpenPanel
# --- Configuration ---
# OpenPanel credentials (write-only, safe to embed in client code)
# These can only send events to our dashboard, not read any data
OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
TELEMETRY_NOTICE = f"""
Basic Memory collects anonymous usage statistics to help improve the software.
This includes: version, OS, feature usage, and errors. No personal data or note content.
To opt out: bm telemetry disable
Details: {TELEMETRY_DOCS_URL}
"""
# --- Module State ---
_client: OpenPanel | None = None
_initialized: bool = False
_telemetry_enabled: bool | None = None # Cached to avoid repeated config reads
# --- Telemetry State ---
def _is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled (cached).
Returns False if:
- User disabled via `bm telemetry disable`
- DO_NOT_TRACK environment variable is set
- Running in test environment
"""
global _telemetry_enabled
if _telemetry_enabled is None:
from basic_memory.config import ConfigManager
config = ConfigManager().config
_telemetry_enabled = config.telemetry_enabled and not config.is_test_env
return _telemetry_enabled
# --- Installation ID ---
def get_install_id() -> str:
"""Get or create anonymous installation ID.
Creates a random UUID on first run and stores it locally.
User can delete ~/.basic-memory/.install_id to reset.
"""
id_file = Path.home() / ".basic-memory" / ".install_id"
if id_file.exists():
return id_file.read_text().strip()
install_id = str(uuid.uuid4())
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text(install_id)
return install_id
# --- Client Management ---
def _get_client() -> OpenPanel | None:
"""Get or create the OpenPanel client (singleton).
Lazily initializes the client with global properties.
Returns None if telemetry is disabled (avoids creating background thread).
"""
global _client, _initialized
# Trigger: telemetry disabled via config, env var, or test mode
# Why: OpenPanel creates a background thread even when disabled=True,
# which can cause hangs on Python 3.14 during thread shutdown
# Outcome: return None early, no OpenPanel client or thread created
if not _is_telemetry_enabled():
return None
if _client is None:
# Defer import to avoid creating background thread when telemetry disabled
from openpanel import OpenPanel
_client = OpenPanel(
client_id=OPENPANEL_CLIENT_ID,
client_secret=OPENPANEL_CLIENT_SECRET,
)
if not _initialized:
install_id = get_install_id()
# Set profile ID for OpenPanel (required for API to accept events)
_client.identify(install_id)
# Set global properties that go with every event
_client.set_global_properties(
{
"app_version": __version__,
"python_version": platform.python_version(),
"os": platform.system().lower(),
"arch": platform.machine(),
"install_id": install_id,
"source": "foss",
}
)
_initialized = True
return _client
def reset_client() -> None:
"""Reset the telemetry client (for testing or after config changes)."""
global _client, _initialized, _telemetry_enabled
_client = None
_initialized = False
_telemetry_enabled = None
def shutdown_telemetry() -> None:
"""Shutdown the telemetry client, stopping its background thread.
Call this on application exit to ensure clean shutdown.
The OpenPanel client creates a background thread with an event loop
that needs to be stopped to avoid hangs on Python 3.14+.
"""
import gc
import io
import sys
global _client
if _client is not None:
try:
# Suppress "Task was destroyed but it is pending!" warnings
# These occur when we stop the event loop with pending HTTP requests,
# which is expected during shutdown. The message is printed directly
# to stderr by asyncio.Task.__del__(), so we redirect stderr temporarily.
# We also force garbage collection to ensure the warning happens
# while stderr is still redirected.
stderr_backup = sys.stderr
sys.stderr = io.StringIO()
try:
# OpenPanel._cleanup stops the event loop and joins the thread
_client._cleanup()
_client = None
# Force garbage collection to trigger Task.__del__ while stderr is redirected
gc.collect()
finally:
sys.stderr = stderr_backup
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry shutdown failed: {e}")
finally:
_client = None
# --- Event Tracking ---
def track(event: str, properties: dict[str, Any] | None = None) -> None:
"""Track an event. Fire-and-forget, never raises.
Args:
event: Event name (e.g., "app_started", "mcp_tool_called")
properties: Optional event properties
"""
# Constraint: telemetry must never break the application
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
try:
client = _get_client()
if client is not None:
client.track(event, properties or {})
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
# --- First-Run Notice ---
def show_notice_if_needed() -> None:
"""Show one-time telemetry notice (Homebrew style).
Only shows if:
- Telemetry is enabled
- Notice hasn't been shown before
After showing, marks the notice as shown in config.
"""
from basic_memory.config import ConfigManager
config_manager = ConfigManager()
config = config_manager.config
if config.telemetry_enabled and not config.telemetry_notice_shown:
from rich.console import Console
from rich.panel import Panel
# Print to stderr so it doesn't interfere with command output
console = Console(stderr=True)
console.print(
Panel(
TELEMETRY_NOTICE.strip(),
title="[dim]Telemetry Notice[/dim]",
border_style="dim",
expand=False,
)
)
# Mark as shown so we don't show again
config.telemetry_notice_shown = True
config_manager.save_config(config)
# --- Convenience Functions ---
def track_app_started(mode: str) -> None:
"""Track app startup.
Args:
mode: "cli" or "mcp"
"""
track("app_started", {"mode": mode})
def track_mcp_tool(tool_name: str) -> None:
"""Track MCP tool usage.
Args:
tool_name: Name of the tool (e.g., "write_note", "search_notes")
"""
track("mcp_tool_called", {"tool": tool_name})
def track_cli_command(command: str) -> None:
"""Track CLI command usage.
Args:
command: Command name (e.g., "sync", "import claude")
"""
track("cli_command", {"command": command})
def track_sync_completed(entity_count: int, duration_ms: int) -> None:
"""Track sync completion.
Args:
entity_count: Number of entities synced
duration_ms: Duration in milliseconds
"""
track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
def track_import_completed(source: str, count: int) -> None:
"""Track import completion.
Args:
source: Import source (e.g., "claude", "chatgpt")
count: Number of items imported
"""
track("import_completed", {"source": source, "count": count})
def track_error(error_type: str, message: str) -> None:
"""Track an error (sanitized).
Args:
error_type: Exception class name
message: Error message (will be sanitized to remove file paths)
"""
if not message:
track("error", {"type": error_type, "message": ""})
return
# Sanitize file paths to prevent leaking user directory structure
# Unix paths: /Users/name/file.py, /home/user/notes/doc.md
sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
# Windows paths: C:\Users\name\file.py, D:\projects\doc.md
sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
# Truncate to avoid sending too much data
track("error", {"type": error_type, "message": sanitized[:200]})
+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
+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
+163
View File
@@ -0,0 +1,163 @@
"""Tests for markdown/utils.py - entity model conversion utilities."""
from datetime import datetime, timezone
from pathlib import Path
import pytest
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation
from basic_memory.markdown.utils import entity_model_from_markdown
from basic_memory.models import Entity
class TestEntityModelFromMarkdown:
"""Tests for entity_model_from_markdown function."""
def _create_markdown(
self,
title: str = "Test Entity",
entity_type: str = "note",
permalink: str = "test/test-entity",
created: datetime | None = None,
modified: datetime | None = None,
observations: list[Observation] | None = None,
) -> EntityMarkdown:
"""Helper to create test EntityMarkdown objects."""
now = datetime.now(timezone.utc)
return EntityMarkdown(
frontmatter=EntityFrontmatter(
title=title,
type=entity_type,
permalink=permalink,
),
content=f"# {title}\n\nTest content.",
observations=observations or [],
relations=[],
created=created or now,
modified=modified or now,
)
def test_new_entity_has_external_id(self):
"""Test that a new entity always gets an external_id set.
This is a regression test for GitHub issue #512 where SQLite failed
with NOT NULL constraint on external_id because SQLAlchemy's Python-side
default wasn't always evaluated.
"""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
entity = entity_model_from_markdown(file_path, markdown)
# external_id must be set (non-None, non-empty)
assert entity.external_id is not None
assert entity.external_id != ""
# Should be a valid UUID format (36 chars with hyphens)
assert len(entity.external_id) == 36
assert entity.external_id.count("-") == 4
def test_existing_entity_preserves_external_id(self):
"""Test that an existing entity's external_id is preserved."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with known external_id
existing_external_id = "12345678-1234-1234-1234-123456789012"
existing_entity = Entity()
existing_entity.external_id = existing_external_id
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should preserve the existing external_id
assert entity.external_id == existing_external_id
def test_entity_with_empty_external_id_gets_new_one(self):
"""Test that an entity with empty string external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with empty external_id
existing_entity = Entity()
existing_entity.external_id = ""
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_entity_with_none_external_id_gets_new_one(self):
"""Test that an entity with None external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with None external_id
existing_entity = Entity()
# Explicitly set to None to test this case
object.__setattr__(existing_entity, "external_id", None)
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_multiple_calls_generate_unique_ids(self):
"""Test that multiple new entities get unique external_ids."""
markdown1 = self._create_markdown(title="Entity 1", permalink="test/entity-1")
markdown2 = self._create_markdown(title="Entity 2", permalink="test/entity-2")
entity1 = entity_model_from_markdown(Path("test/entity-1.md"), markdown1)
entity2 = entity_model_from_markdown(Path("test/entity-2.md"), markdown2)
# Both should have external_ids
assert entity1.external_id is not None
assert entity2.external_id is not None
# They should be unique
assert entity1.external_id != entity2.external_id
def test_entity_model_fields_populated_with_external_id(self):
"""Test that entity fields are populated correctly, including external_id.
This is a basic sanity check that entity_model_from_markdown sets
the key fields we care about for the #512 fix.
"""
markdown = self._create_markdown(
title="My Test Entity",
entity_type="component",
permalink="components/my-test",
)
file_path = Path("components/my-test.md")
entity = entity_model_from_markdown(file_path, markdown, project_id=1)
# The key assertion: external_id must always be set
assert entity.external_id is not None
assert len(entity.external_id) == 36 # UUID format
# Verify file_path is set correctly (uses posix format)
assert entity.file_path == "components/my-test.md"
# Timestamps should be set from markdown
assert entity.created_at is not None
assert entity.updated_at is not None
def test_missing_dates_raises_error(self):
"""Test that missing created/modified dates raise ValueError."""
markdown = EntityMarkdown(
frontmatter=EntityFrontmatter(
title="Test",
type="note",
),
content="# Test",
observations=[],
relations=[],
created=None,
modified=None,
)
with pytest.raises(ValueError, match="Both created and modified dates are required"):
entity_model_from_markdown(Path("test.md"), markdown)
@@ -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."""
-290
View File
@@ -1,290 +0,0 @@
"""Tests for telemetry module (minimal mocking).
We avoid standard-library mocks and instead use small stub objects + pytest monkeypatch.
"""
from __future__ import annotations
from pathlib import Path
from basic_memory.config import BasicMemoryConfig
class _StubOpenPanel:
def __init__(self, *, client_id: str, client_secret: str, disabled: bool = False):
self.client_id = client_id
self.client_secret = client_secret
self.disabled = disabled
self.global_properties: dict | None = None
self.events: list[tuple[str, dict]] = []
self.raise_on_track: Exception | None = None
self.profile_id: str | None = None
def identify(self, profile_id: str) -> None:
self.profile_id = profile_id
def set_global_properties(self, props: dict) -> None:
self.global_properties = props
def track(self, event: str, properties: dict) -> None:
if self.raise_on_track:
raise self.raise_on_track
self.events.append((event, properties))
class _StubConsole:
def __init__(self, *args, **kwargs):
self.print_calls: list[tuple[tuple, dict]] = []
def print(self, *args, **kwargs):
self.print_calls.append((args, kwargs))
class TestGetInstallId:
def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
from basic_memory.telemetry import get_install_id
install_id = get_install_id()
assert len(install_id) == 36
assert install_id.count("-") == 4
id_file = tmp_path / ".basic-memory" / ".install_id"
assert id_file.exists()
assert id_file.read_text().strip() == install_id
def test_returns_existing_install_id(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
id_file = tmp_path / ".basic-memory" / ".install_id"
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text("test-uuid-12345")
from basic_memory.telemetry import get_install_id
assert get_install_id() == "test-uuid-12345"
class TestTelemetryConfig:
def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_enabled is True
def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_notice_shown is False
def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
assert BasicMemoryConfig().telemetry_enabled is False
class TestTrack:
def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
# Replace OpenPanel with a stub that raises on track
stub_client = _StubOpenPanel(client_id="id", client_secret="sec", disabled=False)
stub_client.raise_on_track = Exception("Network error")
def openpanel_factory(*, client_id, client_secret):
stub_client.client_id = client_id
stub_client.client_secret = client_secret
return stub_client
# Mock the import inside _get_client()
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
# Should not raise
telemetry.track("test_event", {"key": "value"})
def test_track_respects_disabled_config(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track("test_event")
# With disabled config, OpenPanel client is never created (early return)
assert len(created) == 0
class TestShowNoticeIfNeeded:
def test_shows_notice_when_enabled_and_not_shown(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
# Ensure config state: enabled + not yet shown
cfg = config_manager.load_config()
cfg.telemetry_enabled = True
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
instances: list[_StubConsole] = []
def console_factory(*_args, **_kwargs):
c = _StubConsole()
instances.append(c)
return c
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
assert len(instances) == 1
assert len(instances[0].print_calls) == 1
cfg2 = config_manager.load_config()
assert cfg2.telemetry_notice_shown is True
def test_does_not_show_notice_when_disabled(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
cfg = config_manager.load_config()
cfg.telemetry_enabled = False
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
def console_factory(*_args, **_kwargs):
raise AssertionError("Console should not be constructed when telemetry is disabled")
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
class TestConvenienceFunctions:
def test_track_app_started(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test (pytest sets PYTEST_CURRENT_TEST)
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_app_started("cli")
assert created
assert created[0].events[-1] == ("app_started", {"mode": "cli"})
def test_track_mcp_tool(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_mcp_tool("write_note")
assert created
assert created[0].events[-1] == ("mcp_tool_called", {"tool": "write_note"})
def test_track_error_truncates_message(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("ValueError", "x" * 500)
_, props = created[0].events[-1]
assert len(props["message"]) == 200
def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
_, props = created[0].events[-1]
assert "/Users/john" not in props["message"]
assert "[FILE]" in props["message"]
telemetry.reset_client()
created.clear()
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
_, props = created[0].events[-1]
assert "C:\\Users\\john" not in props["message"]
assert "[FILE]" in props["message"]
Generated
-14
View File
@@ -156,7 +156,6 @@ dependencies = [
{ name = "mdformat-frontmatter" },
{ name = "mdformat-gfm" },
{ name = "nest-asyncio" },
{ name = "openpanel" },
{ name = "pillow" },
{ name = "psycopg" },
{ name = "pybars3" },
@@ -212,7 +211,6 @@ requires-dist = [
{ name = "mdformat-frontmatter", specifier = ">=2.0.8" },
{ name = "mdformat-gfm", specifier = ">=0.3.7" },
{ name = "nest-asyncio", specifier = ">=1.6.0" },
{ name = "openpanel", specifier = ">=0.0.1" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "psycopg", specifier = "==3.3.1" },
{ name = "pybars3", specifier = ">=0.9.7" },
@@ -1359,18 +1357,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
]
[[package]]
name = "openpanel"
version = "0.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/d2/1ca167988225113a2162fcc528ef309715f348e1ee2bcaa8405222fea08c/openpanel-0.0.1.tar.gz", hash = "sha256:96a27848d670218c03a75528b95b8e3efbd4898665d57b3ee9c81c4b3c06f922", size = 3721, upload-time = "2024-10-16T14:19:41.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/73/44ad513438c56d3e9c8dbdbea647a2f79e8be03a80967eeaa873f29a0527/openpanel-0.0.1-py3-none-any.whl", hash = "sha256:c4d5a31694d4307975bbf70bbb2fa9fae920df0fb4b8ff97fa7b78ee8fe667b2", size = 15865, upload-time = "2024-10-22T19:27:53.139Z" },
]
[[package]]
name = "packaging"
version = "25.0"