Skip to content

Commit 14d5283

Browse files
phernandezclaude
andcommitted
feat: improve cloud promo copy and split init line from upsell panel
- Split "Basic Memory initialized" into a pre-command plain line (shown once on first run) - Rewrite cloud upsell with benefit-led copy: pain point → solution → CTA - Use package version instead of manual CLOUD_PROMO_VERSION for promo gating - Set discount code to BMFOSS - Add "Learn more" link and dimmed opt-out hint below panel - Remove cloud emoji from panel title to fix box alignment - Collapse two message builders into single _build_cloud_promo_message() - Update tests for new behavior (15 tests, all passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e3c0a8c commit 14d5283

33 files changed

Lines changed: 1266 additions & 398 deletions

src/basic_memory/api/v2/routers/schema_router.py

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010

1111
from fastapi import APIRouter, Path, Query
1212

13-
from basic_memory.deps import (
14-
SearchServiceV2ExternalDep,
15-
EntityRepositoryV2ExternalDep,
16-
)
13+
from basic_memory.deps import EntityRepositoryV2ExternalDep
1714
from basic_memory.models.knowledge import Entity
1815
from basic_memory.schemas.schema import (
1916
ValidationReport,
@@ -24,7 +21,6 @@
2421
FieldFrequencyResponse,
2522
DriftFieldResponse,
2623
)
27-
from basic_memory.schemas.search import SearchQuery
2824
from basic_memory.schema.resolver import resolve_schema
2925
from basic_memory.schema.validator import validate_note
3026
from basic_memory.schema.inference import infer_schema, NoteData, ObservationData, RelationData
@@ -81,7 +77,6 @@ def _entity_frontmatter(entity: Entity) -> dict:
8177
@router.post("/schema/validate", response_model=ValidationReport)
8278
async def validate_schema(
8379
entity_repository: EntityRepositoryV2ExternalDep,
84-
search_service: SearchServiceV2ExternalDep,
8580
project_id: str = Path(..., description="Project external UUID"),
8681
entity_type: str | None = Query(None, description="Entity type to validate"),
8782
identifier: str | None = Query(None, description="Specific note identifier"),
@@ -93,18 +88,12 @@ async def validate_schema(
9388
"""
9489
results: list[NoteValidationResponse] = []
9590

96-
async def search_fn(query: str) -> list:
97-
# Search for schema notes, then load full entity_metadata from the entity table.
98-
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
99-
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
100-
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
101-
frontmatters = []
102-
for row in results:
103-
if row.permalink:
104-
entity = await entity_repository.get_by_permalink(row.permalink)
105-
if entity:
106-
frontmatters.append(_entity_frontmatter(entity))
107-
return frontmatters
91+
async def search_fn(query: str) -> list[dict]:
92+
# Trigger: resolve_schema passes the entity type name as query
93+
# Why: direct metadata match avoids fragile text search that fails when
94+
# schema title doesn't match entity type (e.g. "Strict Person" vs "strictperson")
95+
entities = await _find_schema_entities(entity_repository, query)
96+
return [_entity_frontmatter(e) for e in entities]
10897

10998
# --- Single note validation ---
11099
if identifier:
@@ -205,7 +194,6 @@ async def infer_schema_endpoint(
205194
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
206195
async def diff_schema_endpoint(
207196
entity_repository: EntityRepositoryV2ExternalDep,
208-
search_service: SearchServiceV2ExternalDep,
209197
entity_type: str = Path(..., description="Entity type to check for drift"),
210198
project_id: str = Path(..., description="Project external UUID"),
211199
):
@@ -216,25 +204,16 @@ async def diff_schema_endpoint(
216204
fields, and cardinality changes.
217205
"""
218206

219-
async def search_fn(query: str) -> list:
220-
# Search for schema notes, then load full entity_metadata from the entity table.
221-
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
222-
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
223-
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
224-
frontmatters = []
225-
for row in results:
226-
if row.permalink:
227-
entity = await entity_repository.get_by_permalink(row.permalink)
228-
if entity:
229-
frontmatters.append(_entity_frontmatter(entity))
230-
return frontmatters
207+
async def search_fn(query: str) -> list[dict]:
208+
entities = await _find_schema_entities(entity_repository, query)
209+
return [_entity_frontmatter(e) for e in entities]
231210

232211
# Resolve schema by entity type
233212
schema_frontmatter = {"type": entity_type}
234213
schema_def = await resolve_schema(schema_frontmatter, search_fn)
235214

236215
if not schema_def:
237-
return DriftReport(entity_type=entity_type)
216+
return DriftReport(entity_type=entity_type, schema_found=False)
238217

239218
# Collect all notes of this type
240219
entities = await _find_by_entity_type(entity_repository, entity_type)
@@ -281,6 +260,26 @@ async def _find_by_entity_type(
281260
return list(result.scalars().all())
282261

283262

263+
async def _find_schema_entities(
264+
entity_repository: EntityRepositoryV2ExternalDep,
265+
target_entity_type: str,
266+
) -> list[Entity]:
267+
"""Find schema entities whose entity_metadata['entity'] matches the target type.
268+
269+
Queries all schema-type entities, then filters by metadata in Python.
270+
JSON column filtering syntax varies across SQLite/Postgres; an in-memory
271+
filter on the small set of schema notes is simple and portable.
272+
"""
273+
query = entity_repository.select().where(Entity.entity_type == "schema")
274+
result = await entity_repository.execute_query(query)
275+
entities = list(result.scalars().all())
276+
return [
277+
e
278+
for e in entities
279+
if e.entity_metadata and e.entity_metadata.get("entity") == target_entity_type
280+
]
281+
282+
284283
def _to_note_validation_response(result) -> NoteValidationResponse:
285284
"""Convert a core ValidationResult to a Pydantic response model."""
286285
return NoteValidationResponse(

src/basic_memory/cli/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import typer # noqa: E402
1010

1111
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
12-
from basic_memory.cli.promo import maybe_show_cloud_promo # noqa: E402
12+
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
1313
from basic_memory.config import init_cli_logging # noqa: E402
1414

1515

@@ -47,7 +47,15 @@ def app_callback(
4747
container = CliContainer.create()
4848
set_container(container)
4949

50-
maybe_show_cloud_promo(ctx.invoked_subcommand)
50+
# Trigger: first-run init confirmation before command output.
51+
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
52+
# Outcome: one-time plain line printed before the subcommand runs.
53+
maybe_show_init_line(ctx.invoked_subcommand)
54+
55+
# Trigger: register promo as a post-command callback.
56+
# Why: promo output should appear after the command's own output, not before.
57+
# Outcome: promo panel renders below the command results (status tree, table, etc.).
58+
ctx.call_on_close(lambda: maybe_show_cloud_promo(ctx.invoked_subcommand))
5159

5260
# Run initialization for commands that don't use the API
5361
# Skip for 'mcp' command - it has its own lifespan that handles initialization

src/basic_memory/cli/commands/command_utils.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from rich.console import Console
1010

1111
from basic_memory import db
12+
from basic_memory.config import ConfigManager
1213
from basic_memory.mcp.async_client import get_client
13-
1414
from basic_memory.mcp.tools.utils import call_post, call_get
1515
from basic_memory.mcp.project_context import get_active_project
1616
from basic_memory.schemas import ProjectInfoResponse
@@ -55,8 +55,11 @@ async def run_sync(
5555
run_in_background: If True, return immediately; if False, wait for completion
5656
"""
5757

58+
# Resolve default project so get_client() can route per-project
59+
project = project or ConfigManager().default_project
60+
5861
try:
59-
async with get_client() as client:
62+
async with get_client(project_name=project) as client:
6063
project_item = await get_active_project(client, project, None)
6164
url = f"/v2/projects/{project_item.external_id}/sync"
6265
params = []
@@ -88,9 +91,8 @@ async def run_sync(
8891

8992
async def get_project_info(project: str):
9093
"""Get project information via API endpoint."""
91-
9294
try:
93-
async with get_client() as client:
95+
async with get_client(project_name=project) as client:
9496
project_item = await get_active_project(client, project, None)
9597
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
9698
return ProjectInfoResponse.model_validate(response.json())

src/basic_memory/cli/commands/project.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,34 +79,38 @@ async def _list_projects():
7979
table.add_column("Path", style="green")
8080
table.add_column("Mode", style="blue")
8181

82-
# Add Local Path column if in cloud mode and not forcing local
82+
# Add cloud-specific columns when in cloud mode
8383
if config.cloud_mode_enabled and not local:
8484
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
85+
table.add_column("Sync", style="green")
8586

86-
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
87-
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
88-
if show_default_column:
89-
table.add_column("Default", style="magenta")
87+
table.add_column("Default", style="magenta")
9088

9189
for project in result.projects:
9290
is_default = "[X]" if project.is_default else ""
9391
normalized_path = normalize_project_path(project.path)
94-
project_mode = config.get_project_mode(project.name).value
92+
# Trigger: cloud mode and project not in local config
93+
# Why: cloud-discovered projects default to LOCAL in get_project_mode
94+
# Outcome: show "cloud" for projects only known to the cloud API
95+
entry = config.projects.get(project.name)
96+
if config.cloud_mode_enabled and not local and entry is None:
97+
project_mode = ProjectMode.CLOUD.value
98+
else:
99+
project_mode = config.get_project_mode(project.name).value
95100

96101
# Build row based on mode
97102
row = [project.name, format_path(normalized_path), project_mode]
98103

99-
# Add local path if in cloud mode and not forcing local
104+
# Add cloud-specific columns
100105
if config.cloud_mode_enabled and not local:
101106
local_path = ""
102-
entry = config.projects.get(project.name)
103-
if entry and entry.cloud_sync_path:
104-
local_path = format_path(entry.cloud_sync_path)
107+
if entry:
108+
local_path = format_path(entry.cloud_sync_path or entry.path)
105109
row.append(local_path)
110+
has_sync = "[X]" if entry and entry.cloud_sync_path else ""
111+
row.append(has_sync)
106112

107-
# Add default indicator if showing default column
108-
if show_default_column:
109-
row.append(is_default)
113+
row.append(is_default)
110114

111115
table.add_row(*row)
112116

@@ -379,7 +383,7 @@ def set_default_project(
379383
False, "--local", help="Force local API routing (required in cloud mode)"
380384
),
381385
) -> None:
382-
"""Set the default project when 'config.default_project_mode' is set.
386+
"""Set the default project used as fallback when no project is specified.
383387
384388
In cloud mode, use --local to modify the local configuration.
385389
"""

src/basic_memory/cli/commands/routing.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
1010
The routing is controlled via environment variables:
1111
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
12+
- BASIC_MEMORY_EXPLICIT_ROUTING: When "true", signals that --local/--cloud
13+
was explicitly passed, overriding per-project routing in get_client()
1214
- These are checked in basic_memory.mcp.async_client.get_client()
1315
"""
1416

@@ -24,6 +26,11 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
2426
Sets environment variables that are checked by get_client() to determine
2527
whether to use local ASGI transport or cloud proxy transport.
2628
29+
When either flag is set, BASIC_MEMORY_EXPLICIT_ROUTING is also set so
30+
that get_client() skips per-project routing and honors the flag directly.
31+
This only affects CLI commands — the MCP server sets FORCE_LOCAL directly
32+
(without EXPLICIT_ROUTING), so per-project routing still works for MCP tools.
33+
2734
Args:
2835
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
2936
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
4148

4249
# Save original values
4350
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
51+
original_explicit = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
4452

4553
try:
4654
if local:
47-
# Force local routing by setting the env var
4855
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
56+
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
4957
elif cloud:
5058
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
5159
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
60+
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
5261
# If neither is set, don't change anything (use default behavior)
5362
yield
5463
finally:
55-
# Restore original value
64+
# Restore original values
5665
if original_force_local is None:
5766
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
5867
else:
5968
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
6069

70+
if original_explicit is None:
71+
os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None)
72+
else:
73+
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = original_explicit
74+
6175

6276
def validate_routing_flags(local: bool, cloud: bool) -> None:
6377
"""Validate that --local and --cloud flags are not both specified.

0 commit comments

Comments
 (0)