feat: min_similarity override, cloud promo improvements (#570)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-16 16:44:02 -06:00
committed by GitHub
parent 6afe4fd0cc
commit 55d675e278
106 changed files with 7500 additions and 852 deletions
+10 -2
View File
@@ -9,7 +9,7 @@ from typing import Optional # noqa: E402
import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.cli.promo import maybe_show_cloud_promo # noqa: E402
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
@@ -47,7 +47,15 @@ def app_callback(
container = CliContainer.create()
set_container(container)
maybe_show_cloud_promo(ctx.invoked_subcommand)
# Trigger: first-run init confirmation before command output.
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
# Outcome: one-time plain line printed before the subcommand runs.
maybe_show_init_line(ctx.invoked_subcommand)
# Trigger: register promo as a post-command callback.
# Why: promo output should appear after the command's own output, not before.
# Outcome: promo panel renders below the command results (status tree, table, etc.).
ctx.call_on_close(lambda: maybe_show_cloud_promo(ctx.invoked_subcommand))
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
@@ -59,8 +59,7 @@ def login():
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] "
"(20% off for 3 months)\n"
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] (20% off for 3 months)\n"
)
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
@@ -9,8 +9,8 @@ import typer
from rich.console import Console
from basic_memory import db
from basic_memory.config import ConfigManager
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
@@ -55,8 +55,11 @@ async def run_sync(
run_in_background: If True, return immediately; if False, wait for completion
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
try:
async with get_client() as client:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
url = f"/v2/projects/{project_item.external_id}/sync"
params = []
@@ -88,9 +91,8 @@ async def run_sync(
async def get_project_info(project: str):
"""Get project information via API endpoint."""
try:
async with get_client() as client:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
return ProjectInfoResponse.model_validate(response.json())
+62 -51
View File
@@ -13,7 +13,7 @@ from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager, ProjectMode
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
@@ -79,34 +79,38 @@ def list_projects(
table.add_column("Path", style="green")
table.add_column("Mode", style="blue")
# Add Local Path column if in cloud mode and not forcing local
# Add cloud-specific columns when in cloud mode
if config.cloud_mode_enabled and not local:
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
table.add_column("Sync", style="green")
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
if show_default_column:
table.add_column("Default", style="magenta")
table.add_column("Default", style="magenta")
for project in result.projects:
is_default = "[X]" if project.is_default else ""
normalized_path = normalize_project_path(project.path)
project_mode = config.get_project_mode(project.name).value
# Trigger: cloud mode and project not in local config
# Why: cloud-discovered projects default to LOCAL in get_project_mode
# Outcome: show "cloud" for projects only known to the cloud API
entry = config.projects.get(project.name)
if config.cloud_mode_enabled and not local and entry is None:
project_mode = ProjectMode.CLOUD.value
else:
project_mode = config.get_project_mode(project.name).value
# Build row based on mode
row = [project.name, format_path(normalized_path), project_mode]
# Add local path if in cloud mode and not forcing local
# Add cloud-specific columns
if config.cloud_mode_enabled and not local:
local_path = ""
if project.name in config.cloud_projects:
local_path = config.cloud_projects[project.name].local_path or ""
local_path = format_path(local_path)
if entry:
local_path = format_path(entry.cloud_sync_path or entry.path)
row.append(local_path)
has_sync = "[X]" if entry and entry.cloud_sync_path else ""
row.append(has_sync)
# Add default indicator if showing default column
if show_default_column:
row.append(is_default)
row.append(is_default)
table.add_row(*row)
@@ -194,18 +198,20 @@ def add_project(
# Save local sync path to config if in cloud mode
if effective_cloud_mode and local_sync_path:
from basic_memory.config import CloudProjectConfig
# Create local directory if it doesn't exist
local_dir = Path(local_sync_path)
local_dir.mkdir(parents=True, exist_ok=True)
# Update config with sync path
config.cloud_projects[name] = CloudProjectConfig(
local_path=local_sync_path,
last_sync=None,
bisync_initialized=False,
)
# Update project entry with sync path
entry = config.projects.get(name)
if entry:
entry.cloud_sync_path = local_sync_path
else:
# Project may not be in local config yet (cloud-only add)
config.projects[name] = ProjectEntry(
path=local_sync_path,
cloud_sync_path=local_sync_path,
)
ConfigManager().save_config(config)
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
@@ -252,14 +258,17 @@ def setup_project_sync(
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
resolved_path.mkdir(parents=True, exist_ok=True)
# Update local config with sync path
from basic_memory.config import CloudProjectConfig
config.cloud_projects[name] = CloudProjectConfig(
local_path=resolved_path.as_posix(),
last_sync=None,
bisync_initialized=False,
)
# Update project entry with sync path
entry = config.projects.get(name)
if entry:
entry.cloud_sync_path = resolved_path.as_posix()
entry.bisync_initialized = False
entry.last_sync = None
else:
config.projects[name] = ProjectEntry(
path=resolved_path.as_posix(),
cloud_sync_path=resolved_path.as_posix(),
)
config_manager.save_config(config)
console.print(f"[green]Sync configured for project '{name}'[/green]")
@@ -316,8 +325,9 @@ def remove_project(
local_path_config = None
has_bisync_state = False
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
local_path_config = config.cloud_projects[name].local_path
entry = config.projects.get(name)
if config.cloud_mode_enabled and not local and entry and entry.cloud_sync_path:
local_path_config = entry.cloud_sync_path
# Check for bisync state
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
@@ -349,9 +359,11 @@ def remove_project(
shutil.rmtree(bisync_state_path)
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
del config.cloud_projects[name]
# Clean up cloud sync fields on the project entry
if config.cloud_mode_enabled and not local and entry and entry.cloud_sync_path:
entry.cloud_sync_path = None
entry.bisync_initialized = False
entry.last_sync = None
ConfigManager().save_config(config)
# Show informative message if files were not deleted
@@ -371,7 +383,7 @@ def set_default_project(
False, "--local", help="Force local API routing (required in cloud mode)"
),
) -> None:
"""Set the default project when 'config.default_project_mode' is set.
"""Set the default project used as fallback when no project is specified.
In cloud mode, use --local to modify the local configuration.
"""
@@ -618,10 +630,9 @@ def sync_project_command(
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
# Get local_sync_path from project entry
sync_entry = config.projects.get(name)
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
@@ -710,10 +721,9 @@ def bisync_project_command(
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
# Get local_sync_path from project entry
sync_entry = config.projects.get(name)
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
@@ -736,9 +746,11 @@ def bisync_project_command(
if success:
console.print(f"[green]{name} bisync completed successfully[/green]")
# Update config
config.cloud_projects[name].last_sync = datetime.now()
config.cloud_projects[name].bisync_initialized = True
# Update config — sync_entry is guaranteed non-None because
# we checked local_sync_path above (which comes from sync_entry)
assert sync_entry is not None
sync_entry.last_sync = datetime.now()
sync_entry.bisync_initialized = True
ConfigManager().save_config(config)
# Trigger database sync if not a dry run
@@ -805,10 +817,9 @@ def check_project_command(
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
# Get local_sync_path from project entry
check_entry = config.projects.get(name)
local_sync_path = check_entry.cloud_sync_path if check_entry else None
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
+16 -2
View File
@@ -9,6 +9,8 @@ behavior (determined by cloud_mode_enabled in config). This allows users to:
The routing is controlled via environment variables:
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
- BASIC_MEMORY_EXPLICIT_ROUTING: When "true", signals that --local/--cloud
was explicitly passed, overriding per-project routing in get_client()
- These are checked in basic_memory.mcp.async_client.get_client()
"""
@@ -24,6 +26,11 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
Sets environment variables that are checked by get_client() to determine
whether to use local ASGI transport or cloud proxy transport.
When either flag is set, BASIC_MEMORY_EXPLICIT_ROUTING is also set so
that get_client() skips per-project routing and honors the flag directly.
This only affects CLI commands — the MCP server sets FORCE_LOCAL directly
(without EXPLICIT_ROUTING), so per-project routing still works for MCP tools.
Args:
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
cloud: If True, clear force_local to allow cloud routing
@@ -41,23 +48,30 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
# Save original values
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
original_explicit = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
try:
if local:
# Force local routing by setting the env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
elif cloud:
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
# If neither is set, don't change anything (use default behavior)
yield
finally:
# Restore original value
# Restore original values
if original_force_local is None:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
else:
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
if original_explicit is None:
os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None)
else:
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = original_explicit
def validate_routing_flags(local: bool, cloud: bool) -> None:
"""Validate that --local and --cloud flags are not both specified.
+41 -15
View File
@@ -10,7 +10,6 @@ from typing import Annotated, Optional
import typer
from loguru import logger
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from basic_memory.cli.app import app
@@ -49,11 +48,11 @@ async def _run_validate(
"""Run schema validation via the API."""
from basic_memory.mcp.clients.schema import SchemaClient
async with get_client() as client:
async with get_client(project_name=project) as client:
active_project = await get_active_project(client, project, None)
schema_client = SchemaClient(client, active_project.external_id)
# Determine if target is a note identifier or entity type
# Determine if target is a note identifier or note type
# Heuristic: if target contains / or ., treat as identifier
entity_type = None
identifier = None
@@ -70,7 +69,13 @@ async def _run_validate(
# --- Display results ---
if report.total_notes == 0:
console.print("[yellow]No notes matched for validation.[/yellow]")
if report.total_entities == 0:
console.print(f"[yellow]No notes of type '{entity_type}' found.[/yellow]")
else:
console.print(
f"[yellow]Found {report.total_entities} notes but no schema "
f"defined for '{entity_type}'.[/yellow]"
)
return
table = Table(title=f"Schema Validation: {entity_type or identifier or 'all'}")
@@ -109,7 +114,7 @@ async def _run_validate(
def validate(
target: Annotated[
Optional[str],
typer.Argument(help="Note path or entity type to validate"),
typer.Argument(help="Note path or note type to validate"),
] = None,
project: Annotated[
Optional[str],
@@ -123,8 +128,8 @@ def validate(
):
"""Validate notes against their schemas.
TARGET can be a note path (e.g., people/ada-lovelace.md) or an entity type
(e.g., Person). If omitted, validates all notes that have schemas.
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
(e.g., person). If omitted, validates all notes that have schemas.
Use --strict to exit with error code 1 if any validation errors are found.
Use --local to force local routing when cloud mode is enabled.
@@ -158,7 +163,7 @@ async def _run_infer(
"""Run schema inference via the API."""
from basic_memory.mcp.clients.schema import SchemaClient
async with get_client() as client:
async with get_client(project_name=project) as client:
active_project = await get_active_project(client, project, None)
schema_client = SchemaClient(client, active_project.external_id)
@@ -168,6 +173,27 @@ async def _run_infer(
console.print(f"[yellow]No notes found with type: {entity_type}[/yellow]")
return
# --- Empty schema guard ---
# Trigger: notes were analyzed but no fields met the threshold
# Why: dumping hundreds of excluded fields is not useful output
# Outcome: show count and suggest a more specific type
if not report.suggested_schema:
console.print(
f"\n[yellow]Analyzed {report.notes_analyzed} notes of type '{entity_type}', "
f"but no fields met the {threshold:.0%} threshold.[/yellow]\n"
)
console.print(
f"This usually means '{entity_type}' is too broad — "
f"the notes don't share a consistent structure.\n"
)
console.print("[bold]Suggestions:[/bold]")
console.print(" 1. Use a more specific type")
console.print(
f" 2. Lower the threshold: bm schema infer {entity_type} --threshold 0.1"
)
console.print(" 3. Create typed notes with write_note using a specific note_type")
return
# --- Display frequency analysis ---
console.print(
f"\n[bold]Analyzing {report.notes_analyzed} notes with type: {entity_type}...[/bold]\n"
@@ -201,7 +227,7 @@ async def _run_infer(
# --- Display suggested schema ---
console.print("\n[bold]Suggested schema:[/bold]")
console.print(Panel(json.dumps(report.suggested_schema, indent=2), title="Picoschema"))
console.print(json.dumps(report.suggested_schema, indent=2))
if save:
console.print(
@@ -214,7 +240,7 @@ async def _run_infer(
def infer(
entity_type: Annotated[
str,
typer.Argument(help="Entity type to analyze (e.g., Person, meeting)"),
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
],
project: Annotated[
Optional[str],
@@ -231,7 +257,7 @@ def infer(
):
"""Infer schema from existing notes of a type.
Analyzes all notes with the given entity type and suggests a Picoschema
Analyzes all notes with the given type and suggests a Picoschema
definition based on observation and relation frequency.
Fields present in 95%+ of notes become required. Fields above the
@@ -266,7 +292,7 @@ async def _run_diff(
"""Run schema drift detection via the API."""
from basic_memory.mcp.clients.schema import SchemaClient
async with get_client() as client:
async with get_client(project_name=project) as client:
active_project = await get_active_project(client, project, None)
schema_client = SchemaClient(client, active_project.external_id)
@@ -300,7 +326,7 @@ async def _run_diff(
def diff(
entity_type: Annotated[
str,
typer.Argument(help="Entity type to check for drift"),
typer.Argument(help="Note type to check for drift"),
],
project: Annotated[
Optional[str],
@@ -313,8 +339,8 @@ def diff(
):
"""Show drift between schema and actual usage.
Compares the existing schema definition for an entity type against
how notes of that type are actually structured. Identifies new fields,
Compares the existing schema definition against how notes of that type
are actually structured. Identifies new fields,
dropped fields, and cardinality changes.
Use --local to force local routing when cloud mode is enabled.
+4 -1
View File
@@ -12,6 +12,7 @@ from rich.tree import Tree
from basic_memory.cli.app import app
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas import SyncReportResponse
@@ -142,9 +143,11 @@ def display_changes(
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
"""Check sync status of files vs database."""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
try:
async with get_client() as client:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
response = await call_post(client, f"/v2/projects/{project_item.external_id}/status")
sync_report = SyncReportResponse.model_validate(response.json())
+6 -6
View File
@@ -50,7 +50,7 @@ async def _write_note_json(
await mcp_write_note.fn(title, content, folder, project_name, tags)
# Resolve the entity to get metadata back
async with get_client() as client:
async with get_client(project_name=project_name) as client:
active_project = await get_active_project(client, project_name)
knowledge_client = KnowledgeClient(client, active_project.external_id)
@@ -72,7 +72,7 @@ async def _read_note_json(
identifier: str, project_name: Optional[str], page: int, page_size: int
) -> dict:
"""Read a note and return structured JSON with content and metadata."""
async with get_client() as client:
async with get_client(project_name=project_name) as client:
active_project = await get_active_project(client, project_name)
knowledge_client = KnowledgeClient(client, active_project.external_id)
resource_client = ResourceClient(client, active_project.external_id)
@@ -120,7 +120,7 @@ async def _recent_activity_json(
page_size: int = 50,
) -> list:
"""Get recent activity and return structured JSON list."""
async with get_client() as client:
async with get_client(project_name=project_name) as client:
# Build query params matching the MCP tool's logic
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
if depth:
@@ -364,7 +364,7 @@ def build_context(
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
context = run_with_cleanup(
result = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
@@ -375,8 +375,8 @@ def build_context(
max_related=max_related,
)
)
context_dict = context.model_dump(exclude_none=True)
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
# build_context now returns a slimmed dict (already serializable)
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+1
View File
@@ -5,6 +5,7 @@ import warnings
from basic_memory.cli.app import app # pragma: no cover
def _version_only_invocation(argv: list[str]) -> bool:
# Trigger: invocation is exactly `bm --version` or `bm -v`
# Why: avoid importing command modules on the hot version path
+53 -23
View File
@@ -2,14 +2,15 @@
import os
import sys
from collections.abc import Callable
import typer
from rich.console import Console
from rich.panel import Panel
import basic_memory
from basic_memory.config import ConfigManager
CLOUD_PROMO_VERSION = "2026-02-06"
OSS_DISCOUNT_CODE = "{{OSS_DISCOUNT_CODE}}"
OSS_DISCOUNT_CODE = "BMFOSS"
CLOUD_LEARN_MORE_URL = "https://basicmemory.com"
def _promos_disabled_by_env() -> bool:
@@ -23,24 +24,44 @@ def _is_interactive_session() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def _build_first_run_message() -> str:
"""Build first-run cloud promo copy."""
def _build_cloud_promo_message() -> str:
"""Build benefit-led cloud upsell copy with Rich markup."""
return (
"Basic Memory initialized (local mode).\n"
"Cloud is optional and keeps your workflow local-first.\n"
"Cloud adds cross-device sync + mobile/web access.\n"
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
"Run `bm cloud login` to enable."
"☁️ [bold]Your knowledge, everywhere.[/bold] ✨\n"
"Stop losing context when you switch machines.\n"
"Basic Memory Cloud syncs your memory across every device, including mobile and web.\n"
"Try it free for 7 days.\n"
f"Use [bold cyan]{OSS_DISCOUNT_CODE}[/bold cyan] for 20% off when you subscribe.\n"
"[bold green]→ bm cloud login[/bold green]"
)
def _build_version_message() -> str:
"""Build cloud promo copy shown after promo-version bumps."""
return (
"New in Basic Memory Cloud: cross-device sync + mobile/web access.\n"
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
"Run `bm cloud login` to enable."
)
def maybe_show_init_line(
invoked_subcommand: str | None,
*,
config_manager: ConfigManager | None = None,
is_interactive: bool | None = None,
console: Console | None = None,
) -> None:
"""Show a one-time init confirmation line before command output."""
manager = config_manager or ConfigManager()
config = manager.load_config()
interactive = _is_interactive_session() if is_interactive is None else is_interactive
# Same gates as the cloud promo — suppress in non-interactive, env kill-switch,
# mcp/root-help contexts, or when already shown.
if _promos_disabled_by_env() or not interactive:
return
if invoked_subcommand in {None, "mcp"}:
return
if config.cloud_promo_first_run_shown:
return
out = console or Console()
out.print("Basic Memory initialized ✓")
def maybe_show_cloud_promo(
@@ -48,7 +69,7 @@ def maybe_show_cloud_promo(
*,
config_manager: ConfigManager | None = None,
is_interactive: bool | None = None,
echo: Callable[[str], None] = typer.echo,
console: Console | None = None,
) -> None:
"""Show cloud promo copy when discovery gates are satisfied."""
manager = config_manager or ConfigManager()
@@ -72,13 +93,22 @@ def maybe_show_cloud_promo(
return
show_first_run = not config.cloud_promo_first_run_shown
show_version_notice = config.cloud_promo_last_version_shown != CLOUD_PROMO_VERSION
show_version_notice = config.cloud_promo_last_version_shown != basic_memory.__version__
if not show_first_run and not show_version_notice:
return
message = _build_first_run_message() if show_first_run else _build_version_message()
echo(message)
out = console or Console()
out.print(
Panel(
_build_cloud_promo_message(),
title="Basic Memory Cloud",
border_style="cyan",
expand=False,
)
)
out.print(f"Learn more at [link={CLOUD_LEARN_MORE_URL}]{CLOUD_LEARN_MORE_URL}[/link]")
out.print("[dim]Disable with: bm cloud promo --off[/dim]")
config.cloud_promo_first_run_shown = True
config.cloud_promo_last_version_shown = CLOUD_PROMO_VERSION
config.cloud_promo_last_version_shown = basic_memory.__version__
manager.save_config(config)