mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -8,12 +8,11 @@ observations and relations.
|
||||
Flow: Entity loaded with eager observations/relations -> convert to tuples -> core functions.
|
||||
"""
|
||||
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
)
|
||||
from basic_memory.deps import EntityRepositoryV2ExternalDep
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.schemas.schema import (
|
||||
ValidationReport,
|
||||
@@ -24,11 +23,11 @@ from basic_memory.schemas.schema import (
|
||||
FieldFrequencyResponse,
|
||||
DriftFieldResponse,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
from basic_memory.schema.resolver import resolve_schema
|
||||
from basic_memory.schema.validator import validate_note
|
||||
from basic_memory.schema.inference import infer_schema, NoteData, ObservationData, RelationData
|
||||
from basic_memory.schema.diff import diff_schema
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
# Note: No prefix here -- it's added during registration as /v2/{project_id}/schema
|
||||
router = APIRouter(tags=["schema"])
|
||||
@@ -81,7 +80,6 @@ def _entity_frontmatter(entity: Entity) -> dict:
|
||||
@router.post("/schema/validate", response_model=ValidationReport)
|
||||
async def validate_schema(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_type: str | None = Query(None, description="Entity type to validate"),
|
||||
identifier: str | None = Query(None, description="Specific note identifier"),
|
||||
@@ -93,26 +91,24 @@ async def validate_schema(
|
||||
"""
|
||||
results: list[NoteValidationResponse] = []
|
||||
|
||||
async def search_fn(query: str) -> list:
|
||||
# Search for schema notes, then load full entity_metadata from the entity table.
|
||||
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
|
||||
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
|
||||
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
|
||||
frontmatters = []
|
||||
for row in results:
|
||||
if row.permalink:
|
||||
entity = await entity_repository.get_by_permalink(row.permalink)
|
||||
if entity:
|
||||
frontmatters.append(_entity_frontmatter(entity))
|
||||
return frontmatters
|
||||
|
||||
# --- Single note validation ---
|
||||
if identifier:
|
||||
entity = await entity_repository.get_by_permalink(identifier)
|
||||
if not entity:
|
||||
return ValidationReport(entity_type=entity_type, total_notes=0, results=[])
|
||||
|
||||
schema_def = await resolve_schema(_entity_frontmatter(entity), search_fn)
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.permalink or identifier,
|
||||
@@ -135,7 +131,18 @@ async def validate_schema(
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type) if entity_type else []
|
||||
|
||||
for entity in entities:
|
||||
schema_def = await resolve_schema(_entity_frontmatter(entity), search_fn)
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.permalink or entity.file_path,
|
||||
@@ -149,6 +156,7 @@ async def validate_schema(
|
||||
return ValidationReport(
|
||||
entity_type=entity_type,
|
||||
total_notes=len(results),
|
||||
total_entities=len(entities),
|
||||
valid_count=valid,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
error_count=sum(len(r.errors) for r in results),
|
||||
@@ -205,7 +213,6 @@ async def infer_schema_endpoint(
|
||||
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
|
||||
async def diff_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_type: str = Path(..., description="Entity type to check for drift"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
@@ -216,25 +223,16 @@ async def diff_schema_endpoint(
|
||||
fields, and cardinality changes.
|
||||
"""
|
||||
|
||||
async def search_fn(query: str) -> list:
|
||||
# Search for schema notes, then load full entity_metadata from the entity table.
|
||||
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
|
||||
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
|
||||
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
|
||||
frontmatters = []
|
||||
for row in results:
|
||||
if row.permalink:
|
||||
entity = await entity_repository.get_by_permalink(row.permalink)
|
||||
if entity:
|
||||
frontmatters.append(_entity_frontmatter(entity))
|
||||
return frontmatters
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(entity_repository, query)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
# Resolve schema by entity type
|
||||
schema_frontmatter = {"type": entity_type}
|
||||
schema_def = await resolve_schema(schema_frontmatter, search_fn)
|
||||
|
||||
if not schema_def:
|
||||
return DriftReport(entity_type=entity_type)
|
||||
return DriftReport(entity_type=entity_type, schema_found=False)
|
||||
|
||||
# Collect all notes of this type
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
@@ -281,6 +279,54 @@ async def _find_by_entity_type(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _find_schema_entities(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
target_entity_type: str,
|
||||
*,
|
||||
allow_reference_match: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""Find schema entities for resolver lookups.
|
||||
|
||||
Resolution strategy:
|
||||
1) Always try exact entity_metadata['entity'] match (for implicit type lookup
|
||||
and explicit references that use entity names)
|
||||
2) Only when allow_reference_match=True and no entity match was found, try
|
||||
exact reference matching by title/permalink (explicit schema references)
|
||||
"""
|
||||
query = entity_repository.select().where(Entity.entity_type == "schema")
|
||||
result = await entity_repository.execute_query(query)
|
||||
entities = list(result.scalars().all())
|
||||
|
||||
normalized_target = generate_permalink(target_entity_type)
|
||||
|
||||
entity_matches = [
|
||||
e
|
||||
for e in entities
|
||||
if e.entity_metadata
|
||||
and isinstance(e.entity_metadata.get("entity"), str)
|
||||
and generate_permalink(e.entity_metadata["entity"]) == normalized_target
|
||||
]
|
||||
if entity_matches:
|
||||
return entity_matches
|
||||
|
||||
if not allow_reference_match:
|
||||
return []
|
||||
|
||||
reference_matches: list[Entity] = []
|
||||
for entity in entities:
|
||||
candidate_refs: list[str] = []
|
||||
if entity.title:
|
||||
candidate_refs.append(entity.title)
|
||||
if entity.permalink:
|
||||
candidate_refs.append(entity.permalink)
|
||||
candidate_refs.append(FilePath(entity.permalink).name)
|
||||
|
||||
if any(generate_permalink(ref) == normalized_target for ref in candidate_refs):
|
||||
reference_matches.append(entity)
|
||||
|
||||
return reference_matches
|
||||
|
||||
|
||||
def _to_note_validation_response(result) -> NoteValidationResponse:
|
||||
"""Convert a core ValidationResult to a Pydantic response model."""
|
||||
return NoteValidationResponse(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+171
-42
@@ -60,6 +60,9 @@ class CloudProjectConfig(BaseModel):
|
||||
|
||||
This tracks the local working directory and sync state for a project
|
||||
that is synced with Basic Memory Cloud.
|
||||
|
||||
DEPRECATED: Kept for backward-compatible migration only. New code should
|
||||
use ProjectEntry fields (cloud_sync_path, bisync_initialized, last_sync).
|
||||
"""
|
||||
|
||||
local_path: str = Field(description="Local working directory path for this cloud project")
|
||||
@@ -71,26 +74,52 @@ class CloudProjectConfig(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ProjectEntry(BaseModel):
|
||||
"""Unified project configuration entry.
|
||||
|
||||
Replaces the old triple of projects (Dict[str, str]), project_modes
|
||||
(Dict[str, ProjectMode]), and cloud_projects (Dict[str, CloudProjectConfig])
|
||||
with a single structure per project.
|
||||
"""
|
||||
|
||||
path: str = Field(description="Local filesystem path for the project")
|
||||
mode: ProjectMode = Field(
|
||||
default=ProjectMode.LOCAL,
|
||||
description="Routing mode: local (in-process ASGI) or cloud (remote API)",
|
||||
)
|
||||
# Cloud sync state (replaces CloudProjectConfig)
|
||||
cloud_sync_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Local working directory for bisync (formerly CloudProjectConfig.local_path)",
|
||||
)
|
||||
bisync_initialized: bool = Field(
|
||||
default=False,
|
||||
description="Whether rclone bisync baseline has been established",
|
||||
)
|
||||
last_sync: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Timestamp of last successful sync operation",
|
||||
)
|
||||
|
||||
|
||||
class BasicMemoryConfig(BaseSettings):
|
||||
"""Pydantic model for Basic Memory global configuration."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
projects: Dict[str, ProjectEntry] = Field(
|
||||
default_factory=lambda: {
|
||||
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
"main": ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
)
|
||||
}
|
||||
if os.getenv("BASIC_MEMORY_HOME")
|
||||
else {},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
description="Mapping of project names to their ProjectEntry configuration",
|
||||
)
|
||||
default_project: str = Field(
|
||||
default_project: Optional[str] = Field(
|
||||
default="main",
|
||||
description="Name of the default project to use",
|
||||
)
|
||||
default_project_mode: bool = Field(
|
||||
default=True,
|
||||
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
|
||||
description="Name of the default project to use. When set, acts as fallback when no project parameter is specified. Set to null to disable automatic project resolution.",
|
||||
)
|
||||
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
@@ -134,6 +163,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Vector candidate count for vector and hybrid retrieval.",
|
||||
gt=0,
|
||||
)
|
||||
semantic_min_similarity: float = Field(
|
||||
default=0.55,
|
||||
description="Minimum similarity score for vector search results. Results below this threshold are filtered out. 0.0 disables filtering.",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
)
|
||||
|
||||
# Database connection pool configuration (Postgres only)
|
||||
db_pool_size: int = Field(
|
||||
@@ -257,11 +292,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
||||
)
|
||||
|
||||
cloud_projects: Dict[str, CloudProjectConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
cloud_promo_opt_out: bool = Field(
|
||||
default=False,
|
||||
description="Disable CLI cloud promo messages when true.",
|
||||
@@ -282,10 +312,77 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.",
|
||||
)
|
||||
|
||||
project_modes: Dict[str, ProjectMode] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-project routing mode. Projects not listed default to LOCAL.",
|
||||
)
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def migrate_legacy_projects(cls, data: Any) -> Any:
|
||||
"""Migrate old-format config (Dict[str, str]) to new ProjectEntry format.
|
||||
|
||||
Old format stored projects as three separate dicts:
|
||||
projects: {"name": "/path"}
|
||||
project_modes: {"name": "cloud"}
|
||||
cloud_projects: {"name": {"local_path": "...", ...}}
|
||||
|
||||
New format unifies them into:
|
||||
projects: {"name": {"path": "/path", "mode": "cloud", ...}}
|
||||
|
||||
Also removes stale keys (default_project_mode, permalinks_include_project)
|
||||
that are no longer part of the config model.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# --- Remove stale keys from old config versions ---
|
||||
data.pop("default_project_mode", None)
|
||||
|
||||
projects = data.get("projects", {})
|
||||
if not projects:
|
||||
return data
|
||||
|
||||
# Check if already in new format — peek at first value
|
||||
first_value = next(iter(projects.values()), None)
|
||||
if isinstance(first_value, str):
|
||||
# Old format: {"name": "/path"} → convert
|
||||
project_modes = data.pop("project_modes", {})
|
||||
cloud_projects = data.pop("cloud_projects", {})
|
||||
new_projects: Dict[str, Any] = {}
|
||||
for name, path in projects.items():
|
||||
entry: Dict[str, Any] = {"path": path}
|
||||
if name in project_modes:
|
||||
entry["mode"] = project_modes[name]
|
||||
if name in cloud_projects:
|
||||
cp = cloud_projects[name]
|
||||
if isinstance(cp, dict):
|
||||
entry["cloud_sync_path"] = cp.get("local_path")
|
||||
entry["bisync_initialized"] = cp.get("bisync_initialized", False)
|
||||
entry["last_sync"] = cp.get("last_sync")
|
||||
else:
|
||||
# Already a CloudProjectConfig-like object
|
||||
entry["cloud_sync_path"] = getattr(cp, "local_path", None)
|
||||
entry["bisync_initialized"] = getattr(cp, "bisync_initialized", False)
|
||||
entry["last_sync"] = getattr(cp, "last_sync", None)
|
||||
new_projects[name] = entry
|
||||
|
||||
# Pick up cloud_projects entries not already in projects
|
||||
# These are cloud-only projects — path is the cloud permalink,
|
||||
# local_path goes into cloud_sync_path for bisync
|
||||
for name, cp in cloud_projects.items():
|
||||
if name not in new_projects:
|
||||
if isinstance(cp, dict):
|
||||
new_projects[name] = {
|
||||
"path": generate_permalink(name),
|
||||
"mode": project_modes.get(name, "cloud"),
|
||||
"cloud_sync_path": cp.get("local_path"),
|
||||
"bisync_initialized": cp.get("bisync_initialized", False),
|
||||
"last_sync": cp.get("last_sync"),
|
||||
}
|
||||
|
||||
data["projects"] = new_projects
|
||||
else:
|
||||
# New format or dict-based — just clean up stale keys
|
||||
data.pop("project_modes", None)
|
||||
data.pop("cloud_projects", None)
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def is_test_env(self) -> bool:
|
||||
@@ -325,21 +422,26 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
Returns the per-project mode if set, otherwise LOCAL.
|
||||
"""
|
||||
return self.project_modes.get(project_name, ProjectMode.LOCAL)
|
||||
entry = self.projects.get(project_name)
|
||||
return entry.mode if entry else ProjectMode.LOCAL
|
||||
|
||||
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
|
||||
"""Set the routing mode for a project."""
|
||||
if mode == ProjectMode.LOCAL:
|
||||
# Remove from dict to keep config clean — LOCAL is the default
|
||||
self.project_modes.pop(project_name, None)
|
||||
"""Set the routing mode for a project.
|
||||
|
||||
Creates a minimal ProjectEntry if the project doesn't already exist,
|
||||
preserving backward compatibility with code that sets mode before
|
||||
adding a full project entry.
|
||||
"""
|
||||
if project_name in self.projects:
|
||||
self.projects[project_name].mode = mode
|
||||
else:
|
||||
self.project_modes[project_name] = mode
|
||||
self.projects[project_name] = ProjectEntry(path="", mode=mode)
|
||||
|
||||
@classmethod
|
||||
def for_cloud_tenant(
|
||||
cls,
|
||||
database_url: str,
|
||||
projects: Optional[Dict[str, str]] = None,
|
||||
projects: Optional[Dict[str, "ProjectEntry"]] = None,
|
||||
) -> "BasicMemoryConfig":
|
||||
"""Create config for cloud tenant - no config.json, database is source of truth.
|
||||
|
||||
@@ -377,7 +479,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if name not in self.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
return Path(self.projects[name])
|
||||
return Path(self.projects[name].path)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
@@ -387,12 +489,15 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
# Ensure at least one project exists; if none exist then create main
|
||||
if not self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
self.projects["main"] = ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
)
|
||||
|
||||
# Ensure default project is valid (i.e. points to an existing project)
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
# None means "no default" — intentionally left unset
|
||||
if (
|
||||
self.default_project is not None and self.default_project not in self.projects
|
||||
): # pragma: no cover
|
||||
# Set default to first available project
|
||||
self.default_project = next(iter(self.projects.keys()))
|
||||
|
||||
@@ -429,8 +534,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
|
||||
"""Get all configured projects as ProjectConfig objects."""
|
||||
return [
|
||||
ProjectConfig(name=name, home=Path(path), mode=self.get_project_mode(name))
|
||||
for name, path in self.projects.items()
|
||||
ProjectConfig(name=name, home=Path(entry.path), mode=entry.mode)
|
||||
for name, entry in self.projects.items()
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -444,8 +549,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return self
|
||||
|
||||
for name, path_value in self.projects.items():
|
||||
path = Path(path_value)
|
||||
for name, entry in self.projects.items():
|
||||
path = Path(entry.path)
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
@@ -511,6 +616,17 @@ class ConfigManager:
|
||||
try:
|
||||
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
||||
|
||||
# Detect legacy format before model validators strip stale keys
|
||||
_STALE_KEYS = {"default_project_mode", "project_modes", "cloud_projects"}
|
||||
needs_resave = bool(_STALE_KEYS & file_data.keys())
|
||||
|
||||
# Check if projects dict uses old string-value format
|
||||
projects_raw = file_data.get("projects", {})
|
||||
if projects_raw:
|
||||
first_val = next(iter(projects_raw.values()), None)
|
||||
if isinstance(first_val, str):
|
||||
needs_resave = True
|
||||
|
||||
# First, create config from environment variables (Pydantic will read them)
|
||||
# Then overlay with file data for fields that aren't set via env vars
|
||||
# This ensures env vars take precedence
|
||||
@@ -532,6 +648,12 @@ class ConfigManager:
|
||||
merged_data[field_name] = env_dict[field_name]
|
||||
|
||||
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
||||
|
||||
# Re-save to normalize legacy config into current format
|
||||
if needs_resave:
|
||||
logger.info("Migrating config to current format")
|
||||
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
|
||||
|
||||
return _CONFIG_CACHE
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception(f"Failed to load config: {e}")
|
||||
@@ -550,11 +672,15 @@ class ConfigManager:
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
"""Get all configured projects."""
|
||||
return self.config.projects.copy()
|
||||
"""Get all configured projects as name -> path mapping.
|
||||
|
||||
Returns the legacy Dict[str, str] format for backward compatibility
|
||||
with code that expects project name -> filesystem path.
|
||||
"""
|
||||
return {name: entry.path for name, entry in self.config.projects.items()}
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
def default_project(self) -> Optional[str]:
|
||||
"""Get the default project name."""
|
||||
return self.config.default_project
|
||||
|
||||
@@ -570,7 +696,7 @@ class ConfigManager:
|
||||
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = str(project_path)
|
||||
config.projects[name] = ProjectEntry(path=str(project_path))
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
@@ -602,12 +728,15 @@ class ConfigManager:
|
||||
self.save_config(config)
|
||||
|
||||
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
||||
"""Look up a project from the configuration by name or permalink"""
|
||||
"""Look up a project from the configuration by name or permalink.
|
||||
|
||||
Returns (project_name, path_string) for backward compatibility.
|
||||
"""
|
||||
project_permalink = generate_permalink(name)
|
||||
app_config = self.config
|
||||
for project_name, path in app_config.projects.items():
|
||||
for project_name, entry in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(project_name):
|
||||
return project_name, path
|
||||
return project_name, entry.path
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -642,9 +771,9 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
|
||||
project_permalink = generate_permalink(actual_project_name)
|
||||
|
||||
for name, path in app_config.projects.items():
|
||||
for name, entry in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(name):
|
||||
return ProjectConfig(name=name, home=Path(path))
|
||||
return ProjectConfig(name=name, home=Path(entry.path))
|
||||
|
||||
# otherwise raise error
|
||||
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
||||
|
||||
+16
-7
@@ -344,24 +344,33 @@ async def engine_session_factory(
|
||||
|
||||
global _engine, _session_maker
|
||||
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
# Use the same helper function as production code.
|
||||
#
|
||||
# Keep local references so teardown can deterministically dispose the
|
||||
# specific engine created by this context manager, even if other code calls
|
||||
# shutdown_db() and mutates module-level globals mid-test.
|
||||
created_engine, created_session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
_engine, _session_maker = created_engine, created_session_maker
|
||||
|
||||
try:
|
||||
# Verify that engine and session maker are initialized
|
||||
if _engine is None: # pragma: no cover
|
||||
if created_engine is None: # pragma: no cover
|
||||
logger.error("Database engine is None in engine_session_factory")
|
||||
raise RuntimeError("Database engine initialization failed")
|
||||
|
||||
if _session_maker is None: # pragma: no cover
|
||||
if created_session_maker is None: # pragma: no cover
|
||||
logger.error("Session maker is None in engine_session_factory")
|
||||
raise RuntimeError("Session maker initialization failed")
|
||||
|
||||
yield _engine, _session_maker
|
||||
yield created_engine, created_session_maker
|
||||
finally:
|
||||
if _engine:
|
||||
await _engine.dispose()
|
||||
await created_engine.dispose()
|
||||
|
||||
# Only clear module-level globals if they still point to this context's
|
||||
# engine/session. This avoids clobbering newer globals from other callers.
|
||||
if _engine is created_engine:
|
||||
_engine = None
|
||||
if _session_maker is created_session_maker:
|
||||
_session_maker = None
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ This module provides service-layer dependencies:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol
|
||||
|
||||
from fastapi import Depends
|
||||
@@ -446,13 +447,22 @@ def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
|
||||
|
||||
class LocalTaskScheduler:
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task."""
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task.
|
||||
|
||||
In test mode (BASIC_MEMORY_ENV=test), tasks run as no-ops to avoid
|
||||
background asyncio tasks racing against test teardown and causing
|
||||
SQLite 'cannot commit transaction' errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: Mapping[str, Callable[..., Coroutine[Any, Any, None]]],
|
||||
test_mode: bool | None = None,
|
||||
) -> None:
|
||||
self._handlers = handlers
|
||||
self._test_mode = (
|
||||
test_mode if test_mode is not None else os.environ.get("BASIC_MEMORY_ENV") == "test"
|
||||
)
|
||||
|
||||
def schedule(self, task_name: str, **payload: Any) -> None:
|
||||
handler = self._handlers.get(task_name)
|
||||
@@ -461,6 +471,15 @@ class LocalTaskScheduler:
|
||||
# Outcome: fail fast to surface misconfiguration
|
||||
if not handler:
|
||||
raise ValueError(f"Unknown task name: {task_name}")
|
||||
|
||||
# Trigger: running inside pytest (BASIC_MEMORY_ENV=test)
|
||||
# Why: background create_task() outlives test fixtures and races
|
||||
# against engine disposal, causing flaky SQLite errors
|
||||
# Outcome: skip background scheduling; tests exercise the sync
|
||||
# codepaths directly when they need to
|
||||
if self._test_mode:
|
||||
return
|
||||
|
||||
task = asyncio.create_task(handler(**payload))
|
||||
task.add_done_callback(_log_task_failure)
|
||||
|
||||
@@ -516,7 +535,8 @@ async def get_task_scheduler(
|
||||
"sync_entity_vectors": _sync_entity_vectors,
|
||||
"sync_project": _sync_project,
|
||||
"reindex_project": _reindex_project,
|
||||
}
|
||||
},
|
||||
test_mode=app_config.is_test_env,
|
||||
)
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
for chat in conversations:
|
||||
# Get name, providing default for unnamed conversations
|
||||
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
|
||||
date_prefix = datetime.fromisoformat(chat["created_at"].replace("Z", "+00:00")).strftime(
|
||||
"%Y%m%d"
|
||||
)
|
||||
date_prefix = datetime.fromisoformat(
|
||||
chat["created_at"].replace("Z", "+00:00")
|
||||
).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(chat_name)
|
||||
relative_path = (
|
||||
f"{destination_folder}/{date_prefix}-{clean_title}"
|
||||
|
||||
@@ -22,6 +22,19 @@ def _force_local_mode() -> bool:
|
||||
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _explicit_routing() -> bool:
|
||||
"""Check if CLI --local/--cloud flag was explicitly passed.
|
||||
|
||||
Set by force_routing() in CLI commands. When active, --local/--cloud
|
||||
flags override per-project routing. The MCP server sets FORCE_LOCAL
|
||||
directly (without this flag), so per-project routing still works there.
|
||||
|
||||
Returns:
|
||||
True if BASIC_MEMORY_EXPLICIT_ROUTING is set to a truthy value
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
# Optional factory override for dependency injection
|
||||
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
|
||||
|
||||
@@ -56,23 +69,29 @@ async def get_client(
|
||||
1. **Factory injection** (cloud app, tests):
|
||||
If a custom factory is set via set_client_factory(), use that.
|
||||
|
||||
2. **Per-project cloud mode** (project_name provided):
|
||||
If the project's mode is CLOUD, routes to cloud using API key or
|
||||
OAuth token. Honored even when FORCE_LOCAL is set, because the user
|
||||
explicitly declared this project as cloud.
|
||||
2. **CLI explicit override** (BASIC_MEMORY_EXPLICIT_ROUTING env var):
|
||||
When --local or --cloud is explicitly passed via CLI, skip per-project
|
||||
routing and fall through to force-local / global cloud mode handling.
|
||||
This allows users to override per-project mode for commands like
|
||||
`bm status --project specs --local` (check local copy of a cloud project).
|
||||
|
||||
3. **Per-project local mode** (project_name provided):
|
||||
3. **Per-project cloud mode** (project_name provided, no explicit override):
|
||||
If the project's mode is CLOUD, routes to cloud using API key or
|
||||
OAuth token. Honored even when FORCE_LOCAL is set (e.g. MCP server),
|
||||
because the user explicitly declared this project as cloud.
|
||||
|
||||
4. **Per-project local mode** (project_name provided, no explicit override):
|
||||
If the project's mode is LOCAL (or unspecified, default LOCAL), route
|
||||
to local ASGI transport. This allows mixed local/cloud routing even when
|
||||
global cloud mode is enabled.
|
||||
|
||||
4. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
|
||||
5. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
|
||||
Routes to local ASGI transport, ignoring global cloud settings.
|
||||
|
||||
5. **Global cloud mode** (deprecated fallback):
|
||||
When cloud_mode_enabled is True, uses OAuth JWT token.
|
||||
6. **Global cloud mode**:
|
||||
When cloud_mode_enabled is True, uses OAuth JWT token or API key.
|
||||
|
||||
6. **Local mode** (default):
|
||||
7. **Local mode** (default):
|
||||
Use ASGI transport for in-process requests to local FastAPI app.
|
||||
|
||||
Args:
|
||||
@@ -108,53 +127,65 @@ async def get_client(
|
||||
pool=30.0, # 30 seconds for connection pool
|
||||
)
|
||||
|
||||
# Trigger: project has per-project cloud mode set
|
||||
# Why: per-project CLOUD is an explicit user declaration that should be
|
||||
# --- Per-project routing (when project_name given and no CLI override) ---
|
||||
# Trigger: CLI --local/--cloud flag was NOT explicitly passed
|
||||
# Why: per-project routing is an explicit user declaration that should be
|
||||
# honored even from the MCP server (which sets FORCE_LOCAL)
|
||||
# Outcome: HTTP client with API key or OAuth auth to cloud proxy
|
||||
if project_name and config.get_project_mode(project_name) == ProjectMode.CLOUD:
|
||||
# Try API key first (explicit, no network)
|
||||
token = config.cloud_api_key
|
||||
if not token:
|
||||
# Fall back to OAuth session (may refresh token)
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
# Outcome: route based on project's configured mode (CLOUD or LOCAL)
|
||||
if project_name is not None and not _explicit_routing():
|
||||
project_mode = config.get_project_mode(project_name)
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
token = await auth.get_valid_token()
|
||||
if project_mode == ProjectMode.CLOUD:
|
||||
# Try API key first (explicit, no network)
|
||||
token = config.cloud_api_key
|
||||
if not token:
|
||||
# Fall back to OAuth session (may refresh token)
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
f"Project '{project_name}' is set to cloud mode but no credentials found. "
|
||||
"Run 'bm cloud set-key <key>' or 'bm cloud login' first."
|
||||
auth = CLIAuth(
|
||||
client_id=config.cloud_client_id, authkit_domain=config.cloud_domain
|
||||
)
|
||||
token = await auth.get_valid_token()
|
||||
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
f"Project '{project_name}' is set to cloud mode but no credentials "
|
||||
"found. Run 'bm cloud set-key <key>' or 'bm cloud login' first."
|
||||
)
|
||||
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(
|
||||
f"Creating HTTP client for cloud project '{project_name}' at: {proxy_base_url}"
|
||||
)
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(
|
||||
f"Creating HTTP client for cloud project '{project_name}' at: {proxy_base_url}"
|
||||
)
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
# Trigger: project is LOCAL (the default, no CLI override)
|
||||
# Why: project-scoped routing should honor local mode even when global
|
||||
# cloud mode is enabled for backward compatibility
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
else:
|
||||
logger.info(f"Project '{project_name}' is set to local mode - using ASGI transport")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app),
|
||||
base_url="http://test",
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
# Trigger: project is not explicitly cloud (LOCAL is the default)
|
||||
# Why: project-scoped routing should honor local mode even when global
|
||||
# cloud mode is enabled for backward compatibility
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
elif project_name and config.get_project_mode(project_name) == ProjectMode.LOCAL:
|
||||
logger.info(f"Project '{project_name}' is set to local mode - using ASGI transport")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
) as client:
|
||||
yield client
|
||||
# --- Fallback routing (no per-project routing applies) ---
|
||||
|
||||
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
|
||||
# Why: allows local MCP server and CLI commands to route locally
|
||||
# even when cloud_mode_enabled is True
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
elif _force_local_mode():
|
||||
if _force_local_mode():
|
||||
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
|
||||
@@ -84,7 +84,9 @@ def format_search_results_ascii(
|
||||
if query:
|
||||
lines.append(f"Query: {query}")
|
||||
|
||||
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
summary = (
|
||||
f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
)
|
||||
lines.append(_apply_style(summary, ANSI_DIM, color))
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -31,7 +31,6 @@ async def resolve_project_parameter(
|
||||
project: Optional[str] = None,
|
||||
allow_discovery: bool = False,
|
||||
cloud_mode: Optional[bool] = None,
|
||||
default_project_mode: Optional[bool] = None,
|
||||
default_project: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve project parameter using unified linear priority chain.
|
||||
@@ -43,7 +42,7 @@ async def resolve_project_parameter(
|
||||
Resolution order (same for local and cloud modes):
|
||||
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT: project parameter passed directly
|
||||
3. DEFAULT: default project when default_project_mode=true
|
||||
3. DEFAULT: default_project from config (if set)
|
||||
4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE
|
||||
|
||||
Args:
|
||||
@@ -51,26 +50,22 @@ async def resolve_project_parameter(
|
||||
allow_discovery: If True, allows returning None in cloud mode for discovery mode
|
||||
(used by tools like recent_activity that can operate across all projects)
|
||||
cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager.
|
||||
default_project_mode: Optional explicit default project mode. If not provided, reads from ConfigManager.
|
||||
default_project: Optional explicit default project. If not provided, reads from ConfigManager.
|
||||
|
||||
Returns:
|
||||
Resolved project name or None if no resolution possible
|
||||
"""
|
||||
# Load config for any values not explicitly provided
|
||||
if cloud_mode is None or default_project_mode is None or default_project is None:
|
||||
if cloud_mode is None or default_project is None:
|
||||
config = ConfigManager().config
|
||||
if cloud_mode is None:
|
||||
cloud_mode = config.cloud_mode
|
||||
if default_project_mode is None:
|
||||
default_project_mode = config.default_project_mode
|
||||
if default_project is None:
|
||||
default_project = config.default_project
|
||||
|
||||
# Create resolver with configuration and resolve
|
||||
resolver = ProjectResolver.from_env(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
)
|
||||
result = resolver.resolve(project=project, allow_discovery=allow_discovery)
|
||||
@@ -114,7 +109,7 @@ async def get_active_project(
|
||||
project_names = await get_project_names(client, headers)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
||||
"Either set 'default_project' in config, or use 'project' argument.\n"
|
||||
f"Available projects: {project_names}"
|
||||
)
|
||||
|
||||
@@ -225,9 +220,7 @@ async def resolve_project_and_path(
|
||||
if context:
|
||||
context.set_state("active_project", active_project)
|
||||
|
||||
resolved_path = (
|
||||
f"{resolved.permalink}/{remainder}" if include_project else remainder
|
||||
)
|
||||
resolved_path = f"{resolved.permalink}/{remainder}" if include_project else remainder
|
||||
return active_project, resolved_path, True
|
||||
|
||||
# Trigger: no resolvable project prefix in the memory URL
|
||||
@@ -295,7 +288,7 @@ async def get_project_client(
|
||||
project_names = await get_project_names(client)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
||||
"Either set 'default_project' in config, or use 'project' argument.\n"
|
||||
f"Available projects: {project_names}"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ def ai_assistant_guide() -> str:
|
||||
"""Return a concise guide on Basic Memory tools and how to use them.
|
||||
|
||||
Dynamically adapts instructions based on configuration:
|
||||
- Default project mode: Simplified instructions with automatic project
|
||||
- Regular mode: Project discovery and selection guidance
|
||||
- Default project set: Simplified instructions with automatic project fallback
|
||||
- No default project: Project discovery and selection guidance
|
||||
- CLI constraint mode: Single project constraint information
|
||||
|
||||
Returns:
|
||||
@@ -30,34 +30,32 @@ def ai_assistant_guide() -> str:
|
||||
# Check configuration for mode-specific instructions
|
||||
config = ConfigManager().config
|
||||
|
||||
# Add mode-specific header
|
||||
mode_info = ""
|
||||
if config.default_project_mode:
|
||||
# Add mode-specific header based on whether a default project is configured
|
||||
if config.default_project:
|
||||
mode_info = f"""
|
||||
# 🎯 Default Project Mode Active
|
||||
# Default Project Active
|
||||
|
||||
**Current Configuration**: All operations automatically use project '{config.default_project}'
|
||||
**Current Configuration**: Operations automatically fall back to project '{config.default_project}'
|
||||
|
||||
**Simplified Usage**: You don't need to specify the project parameter in tool calls.
|
||||
- `write_note(title="Note", content="...", folder="docs")` ✅
|
||||
- Project parameter is optional and will default to '{config.default_project}'
|
||||
- `write_note(title="Note", content="...", folder="docs")` - uses '{config.default_project}'
|
||||
- To use a different project, explicitly specify: `project="other-project"`
|
||||
|
||||
────────────────────────────────────────
|
||||
---
|
||||
|
||||
"""
|
||||
else: # pragma: no cover
|
||||
mode_info = """
|
||||
# 🔧 Multi-Project Mode Active
|
||||
# Multi-Project Mode
|
||||
|
||||
**Current Configuration**: Project parameter required for all operations
|
||||
**Current Configuration**: No default project set — project parameter required for all operations
|
||||
|
||||
**Project Discovery Required**: Use these tools to select a project:
|
||||
- `list_memory_projects()` - See all available projects
|
||||
- `recent_activity()` - Get project activity and recommendations
|
||||
- Remember the user's project choice throughout the conversation
|
||||
|
||||
────────────────────────────────────────
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
@@ -65,6 +63,7 @@ def ai_assistant_guide() -> str:
|
||||
enhanced_content = mode_info + content
|
||||
|
||||
logger.info(
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) with mode: {'default_project' if config.default_project_mode else 'multi_project'}"
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) "
|
||||
f"with default_project: {config.default_project or 'none'}"
|
||||
)
|
||||
return enhanced_content
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""MCP resources for Basic Memory."""
|
||||
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
from basic_memory.mcp.resources.ui import (
|
||||
note_preview_ui,
|
||||
note_preview_ui_mcp_ui,
|
||||
note_preview_ui_tool_ui,
|
||||
note_preview_ui_vanilla,
|
||||
search_results_ui,
|
||||
search_results_ui_mcp_ui,
|
||||
search_results_ui_tool_ui,
|
||||
search_results_ui_vanilla,
|
||||
)
|
||||
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# from basic_memory.mcp.resources.ui import (
|
||||
# note_preview_ui,
|
||||
# note_preview_ui_mcp_ui,
|
||||
# note_preview_ui_tool_ui,
|
||||
# note_preview_ui_vanilla,
|
||||
# search_results_ui,
|
||||
# search_results_ui_mcp_ui,
|
||||
# search_results_ui_tool_ui,
|
||||
# search_results_ui_vanilla,
|
||||
# )
|
||||
|
||||
__all__ = [
|
||||
"project_info",
|
||||
"note_preview_ui",
|
||||
"note_preview_ui_mcp_ui",
|
||||
"note_preview_ui_tool_ui",
|
||||
"note_preview_ui_vanilla",
|
||||
"search_results_ui",
|
||||
"search_results_ui_mcp_ui",
|
||||
"search_results_ui_tool_ui",
|
||||
"search_results_ui_vanilla",
|
||||
# "note_preview_ui",
|
||||
# "note_preview_ui_mcp_ui",
|
||||
# "note_preview_ui_tool_ui",
|
||||
# "note_preview_ui_vanilla",
|
||||
# "search_results_ui",
|
||||
# "search_results_ui_mcp_ui",
|
||||
# "search_results_ui_tool_ui",
|
||||
# "search_results_ui_vanilla",
|
||||
]
|
||||
|
||||
@@ -14,36 +14,30 @@ Basic Memory creates a semantic knowledge graph from markdown files. Focus on bu
|
||||
|
||||
**Your role**: You're helping humans build enduring knowledge they'll own forever. The semantic graph (observations, relations, context) helps you provide better assistance by understanding connections and maintaining continuity. Think: lasting insights worth keeping, not disposable chat logs.
|
||||
|
||||
## Project Management
|
||||
## Project Management
|
||||
|
||||
All tools require explicit project specification.
|
||||
|
||||
**Three-tier resolution:**
|
||||
1. CLI constraint: `--project name` (highest priority)
|
||||
**Resolution priority:**
|
||||
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
|
||||
2. Explicit parameter: `project="name"` in tool calls
|
||||
3. Default mode: `default_project_mode=true` in config (fallback)
|
||||
3. Default project: `default_project` in config (fallback)
|
||||
|
||||
### Quick Setup Check
|
||||
|
||||
```python
|
||||
# Discover projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
# Check if default_project_mode enabled
|
||||
# If yes: project parameter optional
|
||||
# If no: project parameter required
|
||||
```
|
||||
|
||||
### Default Project Mode
|
||||
### Default Project
|
||||
|
||||
When `default_project_mode=true`:
|
||||
When `default_project` is set in config:
|
||||
```python
|
||||
# These are equivalent:
|
||||
await write_note("Note", "Content", "folder")
|
||||
await write_note("Note", "Content", "folder", project="main")
|
||||
```
|
||||
|
||||
When `default_project_mode=false`:
|
||||
When no `default_project` is configured:
|
||||
```python
|
||||
# Project required:
|
||||
await write_note("Note", "Content", "folder", project="main") # ✓
|
||||
@@ -59,7 +53,7 @@ await write_note(
|
||||
title="Topic",
|
||||
content="# Topic\n## Observations\n- [category] fact\n## Relations\n- relates_to [[Other]]",
|
||||
folder="notes",
|
||||
project="main" # Required unless default_project_mode=true
|
||||
project="main" # Optional if default_project is set in config
|
||||
)
|
||||
```
|
||||
|
||||
@@ -143,12 +137,11 @@ await write_note(
|
||||
### 1. Project Management
|
||||
|
||||
**Single-project users:**
|
||||
- Enable `default_project_mode=true`
|
||||
- Simpler tool calls
|
||||
- Set `default_project` in config (e.g., `"main"`)
|
||||
- Simpler tool calls — project parameter is optional
|
||||
|
||||
**Multi-project users:**
|
||||
- Keep `default_project_mode=false`
|
||||
- Always specify project explicitly
|
||||
- Always specify project explicitly in tool calls
|
||||
|
||||
**Discovery:**
|
||||
```python
|
||||
@@ -200,7 +193,7 @@ Background information
|
||||
**Missing project:**
|
||||
```python
|
||||
try:
|
||||
await search_notes(query="test") # Missing project parameter - will error
|
||||
await search_notes(query="test") # Fails if no default_project configured
|
||||
except:
|
||||
# Show available projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
@@ -38,8 +38,8 @@ async def project_info(
|
||||
|
||||
Args:
|
||||
project: Optional project name. If not provided, uses default_project
|
||||
(if default_project_mode=true) or CLI constraint. If unknown,
|
||||
use list_memory_projects() to discover available projects.
|
||||
from config or CLI constraint. If unknown, use
|
||||
list_memory_projects() to discover available projects.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Basic Memory FastMCP server.
|
||||
"""
|
||||
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.mcp.container import McpContainer, set_container
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
|
||||
@@ -26,7 +28,43 @@ async def lifespan(app: FastMCP):
|
||||
container = McpContainer.create()
|
||||
set_container(container)
|
||||
|
||||
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
config = container.config
|
||||
logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
logger.info(
|
||||
f"Config: database_backend={config.database_backend.value}, "
|
||||
f"semantic_search_enabled={config.semantic_search_enabled}, "
|
||||
f"default_project={config.default_project}"
|
||||
)
|
||||
if config.semantic_search_enabled:
|
||||
logger.info(
|
||||
f"Semantic search: provider={config.semantic_embedding_provider}, "
|
||||
f"model={config.semantic_embedding_model}, "
|
||||
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
|
||||
f"batch_size={config.semantic_embedding_batch_size}"
|
||||
)
|
||||
|
||||
# Log configured projects with their routing mode
|
||||
for name, entry in config.projects.items():
|
||||
default = " (default)" if name == config.default_project else ""
|
||||
logger.info(f"Project: {name} -> {entry.path} [mode={entry.mode.value}]{default}")
|
||||
|
||||
# Check cloud login status (local file check, no network call)
|
||||
if config.cloud_mode:
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
tokens = auth.load_tokens()
|
||||
if tokens is None:
|
||||
logger.warning("Cloud mode enabled but not authenticated - run 'bm cloud login'")
|
||||
elif not auth.is_token_valid(tokens):
|
||||
expires_at = tokens.get("expires_at", 0)
|
||||
expired_ago = int(time.time() - expires_at)
|
||||
logger.warning(f"Cloud token expired {expired_ago}s ago - may need 'bm cloud login'")
|
||||
else:
|
||||
logger.info("Cloud: authenticated (token valid)")
|
||||
|
||||
if config.cloud_api_key:
|
||||
logger.info("Cloud: API key configured (preferred for per-project routing)")
|
||||
else:
|
||||
logger.info("Cloud: no API key set (will use OAuth token for cloud projects)")
|
||||
|
||||
# Track if we created the engine (vs test fixtures providing it)
|
||||
# This prevents disposing an engine provided by test fixtures when
|
||||
|
||||
@@ -11,7 +11,9 @@ from basic_memory.mcp.tools.read_content import read_content
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.cloud_info import cloud_info
|
||||
@@ -48,7 +50,7 @@ __all__ = [
|
||||
"read_content",
|
||||
"read_note",
|
||||
"release_notes",
|
||||
"read_note_ui",
|
||||
# "read_note_ui",
|
||||
"recent_activity",
|
||||
"schema_diff",
|
||||
"schema_infer",
|
||||
@@ -56,7 +58,7 @@ __all__ = [
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
"search_notes_ui",
|
||||
# "search_notes_ui",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,168 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext, MemoryUrl
|
||||
from basic_memory.schemas.memory import (
|
||||
ContextResult,
|
||||
EntitySummary,
|
||||
GraphContext,
|
||||
MemoryUrl,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
)
|
||||
|
||||
# --- Fields to strip from each model (redundant with parent entity) ---
|
||||
|
||||
_OBSERVATION_STRIP = {
|
||||
"observation_id",
|
||||
"entity_id",
|
||||
"entity_external_id",
|
||||
"title",
|
||||
"file_path",
|
||||
"created_at",
|
||||
}
|
||||
_RELATION_STRIP = {
|
||||
"relation_id",
|
||||
"entity_id",
|
||||
"from_entity_id",
|
||||
"from_entity_external_id",
|
||||
"to_entity_id",
|
||||
"to_entity_external_id",
|
||||
"title",
|
||||
"file_path",
|
||||
"created_at",
|
||||
}
|
||||
_ENTITY_STRIP = {"entity_id", "created_at"}
|
||||
_METADATA_STRIP = {"total_results", "generated_at"}
|
||||
|
||||
|
||||
def _slim_summary(summary: EntitySummary | RelationSummary | ObservationSummary) -> dict:
|
||||
"""Strip redundant fields from a summary model based on its type."""
|
||||
if isinstance(summary, ObservationSummary):
|
||||
strip = _OBSERVATION_STRIP
|
||||
elif isinstance(summary, RelationSummary):
|
||||
strip = _RELATION_STRIP
|
||||
else:
|
||||
strip = _ENTITY_STRIP
|
||||
|
||||
data = summary.model_dump()
|
||||
for key in strip:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
|
||||
|
||||
def _slim_context(graph: GraphContext) -> dict:
|
||||
"""Transform GraphContext into a slimmed dict, stripping redundant fields.
|
||||
|
||||
Reduces payload size ~40% by removing fields on nested objects that
|
||||
duplicate information already present on the parent entity (IDs,
|
||||
timestamps, file paths).
|
||||
"""
|
||||
slimmed_results = []
|
||||
for result in graph.results:
|
||||
slimmed_results.append(
|
||||
{
|
||||
"primary_result": _slim_summary(result.primary_result),
|
||||
"observations": [_slim_summary(obs) for obs in result.observations],
|
||||
"related_results": [_slim_summary(rel) for rel in result.related_results],
|
||||
}
|
||||
)
|
||||
|
||||
metadata = graph.metadata.model_dump()
|
||||
for key in _METADATA_STRIP:
|
||||
metadata.pop(key, None)
|
||||
|
||||
return {
|
||||
"results": slimmed_results,
|
||||
"metadata": metadata,
|
||||
"page": graph.page,
|
||||
"page_size": graph.page_size,
|
||||
}
|
||||
|
||||
|
||||
def _format_entity_block(result: ContextResult) -> str:
|
||||
"""Format a single context result as a markdown block."""
|
||||
primary = result.primary_result
|
||||
lines = []
|
||||
|
||||
# --- Header ---
|
||||
lines.append(f"## {primary.title}")
|
||||
if primary.permalink:
|
||||
lines.append(f"permalink: {primary.permalink}")
|
||||
# RelationSummary has no content field; Entity/Observation do
|
||||
if not isinstance(primary, RelationSummary) and primary.content:
|
||||
lines.append("")
|
||||
lines.append(primary.content)
|
||||
|
||||
# --- Observations ---
|
||||
if result.observations:
|
||||
lines.append("")
|
||||
lines.append("### Observations")
|
||||
for obs in result.observations:
|
||||
lines.append(f"- [{obs.category}] {obs.content}")
|
||||
|
||||
# --- Relations (from primary's related_results that are RelationSummary) ---
|
||||
relation_items: list[RelationSummary] = [
|
||||
r for r in result.related_results if isinstance(r, RelationSummary)
|
||||
]
|
||||
if relation_items:
|
||||
lines.append("")
|
||||
lines.append("### Relations")
|
||||
for rel in relation_items:
|
||||
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
|
||||
|
||||
# --- Related entities (non-relation related results) ---
|
||||
related_entities: list[EntitySummary | ObservationSummary] = [
|
||||
r for r in result.related_results if not isinstance(r, RelationSummary)
|
||||
]
|
||||
if related_entities:
|
||||
lines.append("")
|
||||
lines.append("### Related")
|
||||
for item in related_entities:
|
||||
permalink = item.permalink if item.permalink else ""
|
||||
lines.append(f"- [[{item.title}]] ({permalink})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_context_markdown(graph: GraphContext, project: str) -> str:
|
||||
"""Format GraphContext as compact markdown text.
|
||||
|
||||
Produces a human-readable markdown representation that is much smaller
|
||||
than the equivalent JSON, suitable for LLM consumption when structured
|
||||
data isn't needed.
|
||||
"""
|
||||
if not graph.results:
|
||||
uri = graph.metadata.uri or ""
|
||||
return f"No results found for '{uri}' in project '{project}'."
|
||||
|
||||
parts = []
|
||||
|
||||
# --- Title from first primary result ---
|
||||
first_title = graph.results[0].primary_result.title
|
||||
if len(graph.results) == 1:
|
||||
parts.append(f"# Context: {first_title}")
|
||||
else:
|
||||
uri = graph.metadata.uri or ""
|
||||
parts.append(f"# Context: {uri}")
|
||||
|
||||
parts.append("")
|
||||
|
||||
# --- Entity blocks separated by --- ---
|
||||
entity_blocks = [_format_entity_block(result) for result in graph.results]
|
||||
parts.append("\n\n---\n\n".join(entity_blocks))
|
||||
|
||||
# --- Footer ---
|
||||
meta = graph.metadata
|
||||
primary_count = meta.primary_count or 0
|
||||
related_count = meta.related_count or 0
|
||||
parts.append("")
|
||||
parts.append("---")
|
||||
parts.append(
|
||||
f"*{primary_count} primary, {related_count} related"
|
||||
f" | depth={meta.depth} | project: {project}*"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -26,6 +187,10 @@ from basic_memory.schemas.memory import GraphContext, MemoryUrl
|
||||
Timeframes support natural language like:
|
||||
- "2 days ago", "last week", "today", "3 months ago"
|
||||
- Or standard formats like "7d", "24h"
|
||||
|
||||
Format options:
|
||||
- "json" (default): Slimmed JSON with redundant fields removed
|
||||
- "markdown": Compact markdown text for LLM consumption
|
||||
""",
|
||||
)
|
||||
async def build_context(
|
||||
@@ -36,8 +201,9 @@ async def build_context(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
format: str = "json",
|
||||
context: Context | None = None,
|
||||
) -> GraphContext:
|
||||
) -> dict | str:
|
||||
"""Get context needed to continue a discussion within a specific project.
|
||||
|
||||
This tool enables natural continuation of discussions by loading relevant context
|
||||
@@ -58,13 +224,12 @@ async def build_context(
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
format: Response format - "json" for slimmed JSON dict, "markdown" for compact text
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
- primary_results: Content matching the memory:// URI
|
||||
- related_results: Connected content via relations
|
||||
- metadata: Context building details
|
||||
dict (format="json"): Slimmed JSON with redundant fields removed
|
||||
str (format="markdown"): Compact markdown representation
|
||||
|
||||
Examples:
|
||||
# Continue a specific discussion
|
||||
@@ -73,11 +238,8 @@ async def build_context(
|
||||
# Get deeper context about a component
|
||||
build_context("work-docs", "memory://components/memory-service", depth=2)
|
||||
|
||||
# Look at recent changes to a specification
|
||||
build_context("research", "memory://specs/document-format", timeframe="today")
|
||||
|
||||
# Research the history of a feature
|
||||
build_context("dev-notes", "memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
# Get markdown output for compact context
|
||||
build_context("research", "memory://specs/search", format="markdown")
|
||||
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
@@ -97,16 +259,14 @@ async def build_context(
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# Resolve memory:// identifier with project-prefix awareness
|
||||
_, resolved_path, _ = await resolve_project_and_path(
|
||||
client, url, project, context
|
||||
)
|
||||
_, resolved_path, _ = await resolve_project_and_path(client, url, project, context)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import MemoryClient
|
||||
|
||||
# Use typed MemoryClient for API calls
|
||||
memory_client = MemoryClient(client, active_project.external_id)
|
||||
return await memory_client.build_context(
|
||||
graph = await memory_client.build_context(
|
||||
resolved_path,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
@@ -114,3 +274,8 @@ async def build_context(
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
|
||||
if format == "markdown":
|
||||
return _format_context_markdown(graph, active_project.name)
|
||||
|
||||
return _slim_context(graph)
|
||||
|
||||
@@ -16,7 +16,8 @@ from basic_memory.utils import validate_project_path
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a markdown note by title or permalink.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
)
|
||||
async def read_note(
|
||||
identifier: str,
|
||||
@@ -83,9 +84,7 @@ async def read_note(
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# Resolve identifier with project-prefix awareness for memory:// URLs
|
||||
_, entity_path, _ = await resolve_project_and_path(
|
||||
client, identifier, project, context
|
||||
)
|
||||
_, entity_path, _ = await resolve_project_and_path(client, identifier, project, context)
|
||||
|
||||
# Validate identifier to prevent path traversal attacks
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
|
||||
@@ -15,11 +15,66 @@ from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.schema import ValidationReport, InferenceReport, DriftReport
|
||||
|
||||
|
||||
def _no_notes_guidance(note_type: str, tool_name: str) -> str:
|
||||
"""Build guidance string when no notes of a given type exist.
|
||||
|
||||
Used by schema_validate when the project has zero notes of the
|
||||
requested type — a different situation from "notes exist but no schema".
|
||||
"""
|
||||
return (
|
||||
f"# No Notes Found of Type '{note_type}'\n\n"
|
||||
f"`{tool_name}` found no notes with type '{note_type}' in the project.\n\n"
|
||||
f"## Next Steps\n\n"
|
||||
f"1. **Create notes of this type** — use `write_note` with "
|
||||
f'`note_type="{note_type}"` to create notes\n'
|
||||
f"2. **Check existing types** — use `search_notes` with `entity_types` "
|
||||
f"filter to see what types exist\n"
|
||||
f"3. **Browse content** — use `list_directory` or `recent_activity` to "
|
||||
f"see what's in the project\n"
|
||||
)
|
||||
|
||||
|
||||
def _no_schema_guidance(note_type: str, tool_name: str) -> str:
|
||||
"""Build guidance string when no schema exists for a note type.
|
||||
|
||||
Used by schema_validate and schema_diff to explain what happened
|
||||
and how to create a schema.
|
||||
"""
|
||||
return (
|
||||
f"# No Schema Found for '{note_type}'\n\n"
|
||||
f"`{tool_name}` requires a schema note to exist for type '{note_type}'.\n\n"
|
||||
f"## How to Create a Schema\n\n"
|
||||
f'1. **Infer from existing notes** — run `schema_infer("{note_type}")` to '
|
||||
f"analyze your notes and get a suggested schema\n"
|
||||
f"2. **Create a schema note** — write a markdown file with this frontmatter:\n\n"
|
||||
f"```yaml\n"
|
||||
f"---\n"
|
||||
f"title: {note_type.title()}\n"
|
||||
f"type: schema\n"
|
||||
f"entity: {note_type}\n"
|
||||
f"version: 1\n"
|
||||
f"schema:\n"
|
||||
f" name: string, full name\n"
|
||||
f" role?: string, job title\n"
|
||||
f"settings:\n"
|
||||
f" validation: warn\n"
|
||||
f"---\n"
|
||||
f"```\n\n"
|
||||
f"Schema fields use Picoschema notation:\n"
|
||||
f"- `field_name: type, description` — required field\n"
|
||||
f"- `field_name?: type, description` — optional field\n"
|
||||
f"- Supported types: `string`, `number`, `boolean`, `string[]`\n\n"
|
||||
f"3. **Sync** — run `basic-memory sync` or wait for auto-sync to pick up "
|
||||
f"the new schema note\n"
|
||||
f'4. **Re-run** — call `{tool_name}("{note_type}")` again\n'
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Validate notes against their Picoschema definitions.",
|
||||
)
|
||||
async def schema_validate(
|
||||
entity_type: Optional[str] = None,
|
||||
note_type: Optional[str] = None,
|
||||
identifier: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
@@ -32,7 +87,7 @@ async def schema_validate(
|
||||
Schemas are resolved in priority order:
|
||||
1. Inline schema (dict in frontmatter)
|
||||
2. Explicit reference (string in frontmatter)
|
||||
3. Implicit by type (type field matches schema note entity field)
|
||||
3. Implicit by type (type field matches schema note's entity field)
|
||||
4. No schema (no validation)
|
||||
|
||||
Project Resolution:
|
||||
@@ -40,7 +95,7 @@ async def schema_validate(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: Entity type to batch-validate (e.g., "Person").
|
||||
note_type: Note type to batch-validate (e.g., "person", "meeting").
|
||||
If provided, validates all notes of this type.
|
||||
identifier: Specific note to validate (permalink, title, or path).
|
||||
If provided, validates only this note.
|
||||
@@ -51,20 +106,20 @@ async def schema_validate(
|
||||
ValidationReport with per-note results, or error guidance string
|
||||
|
||||
Examples:
|
||||
# Validate all Person notes
|
||||
schema_validate(entity_type="Person")
|
||||
# Validate all person notes
|
||||
schema_validate(note_type="person")
|
||||
|
||||
# Validate a specific note
|
||||
schema_validate(identifier="people/paul-graham")
|
||||
|
||||
# Validate in a specific project
|
||||
schema_validate(entity_type="Person", project="my-research")
|
||||
schema_validate(note_type="person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_validate project={active_project.name} "
|
||||
f"entity_type={entity_type} identifier={identifier}"
|
||||
f"note_type={note_type} identifier={identifier}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -72,7 +127,7 @@ async def schema_validate(
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.validate(
|
||||
entity_type=entity_type,
|
||||
entity_type=note_type,
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
@@ -81,6 +136,21 @@ async def schema_validate(
|
||||
f"total={result.total_notes} valid={result.valid_count} "
|
||||
f"warnings={result.warning_count} errors={result.error_count}"
|
||||
)
|
||||
|
||||
# --- No notes guard ---
|
||||
# Trigger: no entities of this type exist in the project
|
||||
# Why: can't validate notes that don't exist yet
|
||||
# Outcome: return guidance on creating notes of this type
|
||||
if note_type and result.total_entities == 0:
|
||||
return _no_notes_guidance(note_type, "schema_validate")
|
||||
|
||||
# --- No schema guard ---
|
||||
# Trigger: entities exist but none were validated (no schema found)
|
||||
# Why: notes of this type exist but no schema was found, so none were validated
|
||||
# Outcome: return guidance on how to create a schema
|
||||
if note_type and result.total_notes == 0:
|
||||
return _no_schema_guidance(note_type, "schema_validate")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -89,7 +159,7 @@ async def schema_validate(
|
||||
f"# Schema Validation Failed\n\n"
|
||||
f"Error validating schemas: {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure schema notes exist (type: schema) for the target entity type\n"
|
||||
f"1. Ensure schema notes exist (type: schema) for the target note type\n"
|
||||
f"2. Check that notes have the correct type in frontmatter\n"
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
@@ -99,7 +169,7 @@ async def schema_validate(
|
||||
description="Analyze existing notes and suggest a Picoschema definition.",
|
||||
)
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
@@ -120,7 +190,7 @@ async def schema_infer(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to analyze (e.g., "Person", "meeting").
|
||||
note_type: The note type to analyze (e.g., "person", "meeting").
|
||||
threshold: Minimum frequency (0-1) for a field to be suggested as optional.
|
||||
Default 0.25 (25%). Fields above 95% become required.
|
||||
project: Project name. Optional -- server will resolve.
|
||||
@@ -130,44 +200,68 @@ async def schema_infer(
|
||||
InferenceReport with frequency data and suggested schema, or error string
|
||||
|
||||
Examples:
|
||||
# Infer schema for Person notes
|
||||
schema_infer("Person")
|
||||
# Infer schema for person notes
|
||||
schema_infer("person")
|
||||
|
||||
# Use a higher threshold (50% minimum)
|
||||
schema_infer("meeting", threshold=0.5)
|
||||
|
||||
# Infer in a specific project
|
||||
schema_infer("Person", project="my-research")
|
||||
schema_infer("person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_infer project={active_project.name} "
|
||||
f"entity_type={entity_type} threshold={threshold}"
|
||||
f"note_type={note_type} threshold={threshold}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.infer(entity_type, threshold=threshold)
|
||||
result = await schema_client.infer(note_type, threshold=threshold)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_infer project={active_project.name} "
|
||||
f"entity_type={entity_type} notes_analyzed={result.notes_analyzed} "
|
||||
f"note_type={note_type} notes_analyzed={result.notes_analyzed} "
|
||||
f"required={len(result.suggested_required)} "
|
||||
f"optional={len(result.suggested_optional)}"
|
||||
)
|
||||
|
||||
# --- Empty schema guard ---
|
||||
# Trigger: notes were analyzed but no fields met the threshold
|
||||
# Why: returning hundreds of excluded fields overwhelms the LLM context
|
||||
# Outcome: return actionable guidance instead of a massive empty result
|
||||
if result.notes_analyzed > 0 and not result.suggested_schema:
|
||||
return (
|
||||
f"# No Schema Pattern Found\n\n"
|
||||
f"Analyzed {result.notes_analyzed} notes of type '{note_type}', "
|
||||
f"but no observation or relation appeared in enough notes to suggest "
|
||||
f"a schema (threshold: {threshold:.0%}).\n\n"
|
||||
f"This usually means '{note_type}' is too broad — the notes don't "
|
||||
f"share a consistent structure.\n\n"
|
||||
f"## Suggestions\n"
|
||||
f"1. **Use a more specific type** — try `search_notes` with "
|
||||
f"`entity_types` filter to see what types exist\n"
|
||||
f"2. **Lower the threshold** — "
|
||||
f'`schema_infer("{note_type}", threshold=0.1)` to include '
|
||||
f"rarer fields\n"
|
||||
f"3. **Create typed notes** — use `write_note` with a specific "
|
||||
f'`note_type` (e.g., "person", "meeting") to build consistent '
|
||||
f"structure\n"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schema inference failed: {e}, project: {active_project.name}")
|
||||
return (
|
||||
f"# Schema Inference Failed\n\n"
|
||||
f"Error inferring schema for '{entity_type}': {e}\n\n"
|
||||
f"Error inferring schema for type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure notes of type '{entity_type}' exist in the project\n"
|
||||
f'2. Try searching: `search_notes("{entity_type}", types=["{entity_type}"])`\n'
|
||||
f"1. Ensure notes of type '{note_type}' exist in the project\n"
|
||||
f'2. Try searching: `search_notes("{note_type}", types=["{note_type}"])`\n'
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
|
||||
@@ -176,13 +270,13 @@ async def schema_infer(
|
||||
description="Detect drift between a schema definition and actual note usage.",
|
||||
)
|
||||
async def schema_diff(
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> DriftReport | str:
|
||||
"""Detect drift between a schema definition and actual note usage.
|
||||
|
||||
Compares the existing schema for an entity type against how notes of
|
||||
Compares the existing schema for a note type against how notes of
|
||||
that type are actually structured. Identifies new fields that have
|
||||
appeared, declared fields that are rarely used, and cardinality changes
|
||||
(single-value vs array).
|
||||
@@ -195,7 +289,7 @@ async def schema_diff(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to check for drift (e.g., "Person").
|
||||
note_type: The note type to check for drift (e.g., "person").
|
||||
project: Project name. Optional -- server will resolve.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
@@ -204,41 +298,48 @@ async def schema_diff(
|
||||
or error guidance string
|
||||
|
||||
Examples:
|
||||
# Check drift for Person schema
|
||||
schema_diff("Person")
|
||||
# Check drift for person schema
|
||||
schema_diff("person")
|
||||
|
||||
# Check drift in a specific project
|
||||
schema_diff("Person", project="my-research")
|
||||
schema_diff("person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_diff project={active_project.name} "
|
||||
f"entity_type={entity_type}"
|
||||
f"MCP tool call tool=schema_diff project={active_project.name} note_type={note_type}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.diff(entity_type)
|
||||
result = await schema_client.diff(note_type)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_diff project={active_project.name} "
|
||||
f"entity_type={entity_type} "
|
||||
f"note_type={note_type} schema_found={result.schema_found} "
|
||||
f"new_fields={len(result.new_fields)} "
|
||||
f"dropped_fields={len(result.dropped_fields)} "
|
||||
f"cardinality_changes={len(result.cardinality_changes)}"
|
||||
)
|
||||
|
||||
# --- No schema guard ---
|
||||
# Trigger: API reports no schema was found for this type
|
||||
# Why: diff requires a schema to compare against
|
||||
# Outcome: return guidance on how to create a schema
|
||||
if not result.schema_found:
|
||||
return _no_schema_guidance(note_type, "schema_diff")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schema diff failed: {e}, project: {active_project.name}")
|
||||
return (
|
||||
f"# Schema Diff Failed\n\n"
|
||||
f"Error detecting drift for '{entity_type}': {e}\n\n"
|
||||
f"Error detecting drift for type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure a schema note exists for entity type '{entity_type}'\n"
|
||||
f"2. Ensure notes of type '{entity_type}' exist in the project\n"
|
||||
f"1. Ensure a schema note exists for type '{note_type}'\n"
|
||||
f"2. Ensure notes of type '{note_type}' exist in the project\n"
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List, Optional, Dict, Any, Literal
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.container import get_container
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.mcp.formatting import format_search_results_ascii
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -231,7 +232,8 @@ Error searching for '{query}': {error_message}
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
)
|
||||
async def search_notes(
|
||||
query: str,
|
||||
@@ -246,6 +248,7 @@ async def search_notes(
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
min_similarity: Optional[float] = None,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
"""Search across all content in the knowledge base with comprehensive syntax support.
|
||||
@@ -322,7 +325,7 @@ async def search_notes(
|
||||
page: The page number of results to return (default 1)
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of:
|
||||
"text", "title", "permalink", "vector", "hybrid" (default: "text")
|
||||
"text", "title", "permalink", "vector", "semantic", "hybrid" (default: "text")
|
||||
output_format: "default" returns structured data, "ascii" returns a plain text table,
|
||||
"ansi" returns a colorized table for TUI clients.
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
@@ -331,6 +334,9 @@ async def search_notes(
|
||||
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
|
||||
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
|
||||
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
|
||||
min_similarity: Optional float to override the global semantic_min_similarity threshold
|
||||
for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision.
|
||||
Only applies to vector and hybrid search types.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -408,44 +414,57 @@ async def search_notes(
|
||||
query = resolved_query
|
||||
search_type = "permalink"
|
||||
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Set the appropriate search field based on search_type
|
||||
if search_type == "text":
|
||||
search_query.text = query
|
||||
elif search_type == "vector":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif search_type == "hybrid":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif search_type == "title":
|
||||
search_query.title = query
|
||||
elif search_type == "permalink" and "*" in query:
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else: # pragma: no cover
|
||||
search_query.text = query # Default to text search
|
||||
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
|
||||
try:
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Map search_type to the appropriate query field and retrieval mode
|
||||
valid_search_types = {"text", "title", "permalink", "vector", "semantic", "hybrid"}
|
||||
if search_type == "text":
|
||||
search_query.text = query
|
||||
# Upgrade to hybrid when semantic search is available —
|
||||
# combines FTS keyword matching with vector similarity for better results
|
||||
try:
|
||||
container = get_container()
|
||||
if container.config.semantic_search_enabled:
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
except RuntimeError:
|
||||
pass # Container not initialized (e.g., CLI context) — stay with FTS
|
||||
elif search_type in ("vector", "semantic"):
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif search_type == "hybrid":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif search_type == "title":
|
||||
search_query.title = query
|
||||
elif search_type == "permalink" and "*" in query:
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid search_type '{search_type}'. "
|
||||
f"Valid options: {', '.join(sorted(valid_search_types))}"
|
||||
)
|
||||
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
if min_similarity is not None:
|
||||
search_query.min_similarity = min_similarity
|
||||
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
# Import here to avoid circular import (tools → clients → utils → tools)
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ async def write_note(
|
||||
project: Optional[str] = None,
|
||||
tags: list[str] | str | None = None,
|
||||
note_type: str = "note",
|
||||
metadata: dict | None = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
@@ -67,6 +68,9 @@ async def write_note(
|
||||
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
||||
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
|
||||
Can be "guide", "report", "config", "person", etc.
|
||||
metadata: Optional dict of extra frontmatter fields merged into entity_metadata.
|
||||
Useful for schema notes or any note that needs custom YAML frontmatter
|
||||
beyond title/type/tags. Nested dicts are supported.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -105,6 +109,20 @@ async def write_note(
|
||||
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
|
||||
)
|
||||
|
||||
# Create a schema note with custom frontmatter via metadata
|
||||
write_note(
|
||||
title="Person",
|
||||
directory="schemas",
|
||||
note_type="schema",
|
||||
content="# Person\\n\\nSchema for person entities.",
|
||||
metadata={
|
||||
"entity": "person",
|
||||
"version": 1,
|
||||
"schema": {"name": "string", "role?": "string"},
|
||||
"settings": {"validation": "warn"},
|
||||
},
|
||||
)
|
||||
|
||||
Raises:
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If directory path attempts path traversal
|
||||
@@ -130,15 +148,22 @@ async def write_note(
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
# Create the entity request
|
||||
metadata = {"tags": tag_list} if tag_list else None
|
||||
|
||||
# Build entity_metadata from optional metadata, then explicit tags on top
|
||||
# Order matters: explicit tags parameter takes precedence over metadata["tags"]
|
||||
entity_metadata = {}
|
||||
if metadata:
|
||||
entity_metadata.update(metadata)
|
||||
if tag_list:
|
||||
entity_metadata["tags"] = tag_list
|
||||
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
entity_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
entity_metadata=entity_metadata or None,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
|
||||
@@ -8,7 +8,7 @@ identically in both local and cloud modes:
|
||||
|
||||
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT: Project passed directly to operation
|
||||
3. DEFAULT: Default project when default_project_mode=true
|
||||
3. DEFAULT: default_project from config (if set)
|
||||
4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,7 @@ class ResolutionMode(Enum):
|
||||
CLOUD_DISCOVERY = auto() # Discovery mode allowed in cloud (no project)
|
||||
ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var
|
||||
EXPLICIT = auto() # Explicit project parameter
|
||||
DEFAULT = auto() # default_project with default_project_mode=true
|
||||
DEFAULT = auto() # default_project from config
|
||||
NONE = auto() # No resolution possible
|
||||
|
||||
|
||||
@@ -70,14 +70,12 @@ class ProjectResolver:
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
default_project: The default project name (used as fallback when set)
|
||||
constrained_project: Optional env-constrained project override
|
||||
(typically from BASIC_MEMORY_MCP_PROJECT)
|
||||
"""
|
||||
|
||||
cloud_mode: bool = False
|
||||
default_project_mode: bool = False
|
||||
default_project: Optional[str] = None
|
||||
constrained_project: Optional[str] = None
|
||||
|
||||
@@ -85,14 +83,12 @@ class ProjectResolver:
|
||||
def from_env(
|
||||
cls,
|
||||
cloud_mode: bool = False,
|
||||
default_project_mode: bool = False,
|
||||
default_project: Optional[str] = None,
|
||||
) -> "ProjectResolver":
|
||||
"""Create resolver with constrained_project from environment.
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
|
||||
Returns:
|
||||
@@ -101,7 +97,6 @@ class ProjectResolver:
|
||||
constrained = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
return cls(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
constrained_project=constrained,
|
||||
)
|
||||
@@ -116,7 +111,7 @@ class ProjectResolver:
|
||||
The same resolution order applies in both local and cloud modes:
|
||||
1. ENV_CONSTRAINT — BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT — project parameter passed directly
|
||||
3. DEFAULT — default project when default_project_mode=true
|
||||
3. DEFAULT — default_project from config (if set)
|
||||
4. Fallback — cloud: CLOUD_DISCOVERY or ValueError; local: NONE
|
||||
|
||||
Args:
|
||||
@@ -150,13 +145,13 @@ class ProjectResolver:
|
||||
reason=f"Explicit parameter: {project}",
|
||||
)
|
||||
|
||||
# --- Priority 3: Default project mode ---
|
||||
if self.default_project_mode and self.default_project:
|
||||
# --- Priority 3: Default project from config ---
|
||||
if self.default_project:
|
||||
logger.debug(f"Using default project from config: {self.default_project}")
|
||||
return ResolvedProject(
|
||||
project=self.default_project,
|
||||
mode=ResolutionMode.DEFAULT,
|
||||
reason=f"Default project mode: {self.default_project}",
|
||||
reason=f"Default project: {self.default_project}",
|
||||
)
|
||||
|
||||
# --- Fallback: mode-dependent behavior ---
|
||||
@@ -168,7 +163,7 @@ class ProjectResolver:
|
||||
mode=ResolutionMode.CLOUD_DISCOVERY,
|
||||
reason="Discovery mode enabled in cloud",
|
||||
)
|
||||
raise ValueError("No project specified. Project is required for cloud mode.")
|
||||
raise ValueError("No project specified. Project is required.")
|
||||
|
||||
# Local mode: no resolution possible
|
||||
logger.debug("No project resolution possible")
|
||||
@@ -200,7 +195,7 @@ class ProjectResolver:
|
||||
result = self.resolve(project, allow_discovery=False)
|
||||
if not result.is_resolved:
|
||||
msg = error_message or (
|
||||
"No project specified. Either set 'default_project_mode=true' in config, "
|
||||
"No project specified. Either set 'default_project' in config, "
|
||||
"or provide a 'project' argument."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
@@ -51,6 +51,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
self._app_config = app_config or ConfigManager().config
|
||||
self._semantic_enabled = self._app_config.semantic_search_enabled
|
||||
self._semantic_vector_k = self._app_config.semantic_vector_k
|
||||
self._semantic_min_similarity = self._app_config.semantic_min_similarity
|
||||
self._embedding_provider = embedding_provider
|
||||
self._vector_dimensions = 384
|
||||
self._vector_tables_initialized = False
|
||||
@@ -64,17 +65,16 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
async def init_search_index(self):
|
||||
"""Create Postgres table with tsvector column and GIN indexes.
|
||||
|
||||
Note: This is handled by Alembic migrations. This method is a no-op
|
||||
for Postgres as the schema is created via migrations.
|
||||
Note: FTS schema is handled by Alembic migrations. Vector tables are
|
||||
created here at startup so missing pgvector or provider errors surface
|
||||
immediately.
|
||||
"""
|
||||
logger.info("PostgreSQL search index initialization handled by migrations")
|
||||
# Table creation is done via Alembic migrations
|
||||
# This includes:
|
||||
# - CREATE TABLE search_index (...)
|
||||
# - ADD COLUMN textsearchable_index_col tsvector GENERATED ALWAYS AS (...)
|
||||
# - CREATE INDEX USING GIN on textsearchable_index_col
|
||||
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
|
||||
pass
|
||||
|
||||
# Fail fast: create vector tables at startup so missing pgvector
|
||||
# or embedding provider errors surface immediately
|
||||
if self._semantic_enabled:
|
||||
await self._ensure_vector_tables()
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index or update a single item using UPSERT.
|
||||
@@ -260,6 +260,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
|
||||
logger.info("Ensuring Postgres vector tables exist for semantic search")
|
||||
|
||||
async with self._vector_tables_lock:
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
@@ -349,6 +351,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Postgres vector tables ready (dimensions={self._vector_dimensions})")
|
||||
self._vector_tables_initialized = True
|
||||
|
||||
async def _get_existing_embedding_dims(self, session: AsyncSession) -> int | None:
|
||||
@@ -587,6 +590,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -602,6 +606,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ class SearchRepository(Protocol):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
|
||||
@@ -49,6 +49,7 @@ class SearchRepositoryBase(ABC):
|
||||
# --- Subclass-populated attributes ---
|
||||
_semantic_enabled: bool
|
||||
_semantic_vector_k: int
|
||||
_semantic_min_similarity: float
|
||||
_embedding_provider: Optional[EmbeddingProvider]
|
||||
_vector_dimensions: int
|
||||
_vector_tables_initialized: bool
|
||||
@@ -112,6 +113,7 @@ class SearchRepositoryBase(ABC):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -753,6 +755,7 @@ class SearchRepositoryBase(ABC):
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
retrieval_mode: SearchRetrievalMode,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> Optional[List[SearchIndexRow]]:
|
||||
@@ -784,6 +787,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -802,6 +806,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -830,6 +835,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -843,7 +849,7 @@ class SearchRepositoryBase(ABC):
|
||||
await self._ensure_vector_tables()
|
||||
assert self._embedding_provider is not None
|
||||
query_embedding = await self._embedding_provider.embed_query(search_text.strip())
|
||||
candidate_limit = max(self._semantic_vector_k, (limit + offset) * 5)
|
||||
candidate_limit = max(self._semantic_vector_k, (limit + offset) * 10)
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await self._prepare_vector_session(session)
|
||||
@@ -872,6 +878,18 @@ class SearchRepositoryBase(ABC):
|
||||
if not similarity_by_si_id:
|
||||
return []
|
||||
|
||||
# Filter out results below the minimum similarity threshold.
|
||||
# Per-query min_similarity overrides the instance-level default.
|
||||
effective_min_similarity = (
|
||||
min_similarity if min_similarity is not None else self._semantic_min_similarity
|
||||
)
|
||||
if effective_min_similarity > 0.0:
|
||||
similarity_by_si_id = {
|
||||
k: v for k, v in similarity_by_si_id.items() if v >= effective_min_similarity
|
||||
}
|
||||
if not similarity_by_si_id:
|
||||
return []
|
||||
|
||||
# Fetch the actual search_index rows
|
||||
si_ids = list(similarity_by_si_id.keys())
|
||||
search_index_rows = await self._fetch_search_index_rows_by_ids(si_ids)
|
||||
@@ -1029,6 +1047,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -1061,26 +1080,39 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=candidate_limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# RRF fusion keyed on search_index row id for granular results.
|
||||
# This allows observations and relations to surface as individual results,
|
||||
# not collapsed into their parent entity.
|
||||
# Score-weighted RRF fusion keyed on search_index row id.
|
||||
# Multiplies the standard 1/(k+rank) score by the normalized original score
|
||||
# so that high-confidence matches contribute more than weak ones at the same rank.
|
||||
fused_scores: dict[int, float] = {}
|
||||
rows_by_id: dict[int, SearchIndexRow] = {}
|
||||
|
||||
# Normalize FTS scores to [0, 1] — handles both SQLite (negative bm25)
|
||||
# and Postgres (positive ts_rank) by using absolute values
|
||||
fts_abs = [abs(row.score or 0.0) for row in fts_results]
|
||||
fts_max = max(fts_abs) if fts_abs else 1.0
|
||||
|
||||
for rank, row in enumerate(fts_results, start=1):
|
||||
if row.id is None:
|
||||
continue
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank))
|
||||
norm = abs(row.score or 0.0) / fts_max if fts_max > 0 else 0.0
|
||||
weight = max(norm, 0.1) # floor preserves RRF stability
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + weight * (1.0 / (RRF_K + rank))
|
||||
rows_by_id[row.id] = row
|
||||
|
||||
# Vector scores already in [0, 1] from the similarity formula
|
||||
vec_max = max((row.score or 0.0) for row in vector_results) if vector_results else 1.0
|
||||
|
||||
for rank, row in enumerate(vector_results, start=1):
|
||||
if row.id is None:
|
||||
continue
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank))
|
||||
norm = (row.score or 0.0) / vec_max if vec_max > 0 else 0.0
|
||||
weight = max(norm, 0.1) # floor preserves RRF stability
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + weight * (1.0 / (RRF_K + rank))
|
||||
rows_by_id[row.id] = row
|
||||
|
||||
ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True)
|
||||
|
||||
@@ -51,6 +51,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
self._app_config = app_config or ConfigManager().config
|
||||
self._semantic_enabled = self._app_config.semantic_search_enabled
|
||||
self._semantic_vector_k = self._app_config.semantic_vector_k
|
||||
self._semantic_min_similarity = self._app_config.semantic_min_similarity
|
||||
self._embedding_provider = embedding_provider
|
||||
self._sqlite_vec_lock = asyncio.Lock()
|
||||
self._vector_tables_initialized = False
|
||||
@@ -72,7 +73,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
"""Create FTS5 virtual table for search if it doesn't exist.
|
||||
|
||||
Uses CREATE VIRTUAL TABLE IF NOT EXISTS to preserve existing indexed data
|
||||
across server restarts.
|
||||
across server restarts. Also creates vector tables when semantic search
|
||||
is enabled so missing dependencies are caught at startup, not first query.
|
||||
"""
|
||||
logger.info("Initializing SQLite FTS5 search index")
|
||||
try:
|
||||
@@ -84,6 +86,11 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
# Fail fast: create vector tables at startup so missing sqlite-vec
|
||||
# or embedding provider errors surface immediately
|
||||
if self._semantic_enabled:
|
||||
await self._ensure_vector_tables()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FTS5 query preparation (backend-specific)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -367,6 +374,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
|
||||
logger.info("Ensuring SQLite vector tables exist for semantic search")
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await self._ensure_sqlite_vec_loaded(session)
|
||||
|
||||
@@ -386,6 +395,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
}
|
||||
schema_mismatch = bool(chunks_columns) and set(chunks_columns) != expected_columns
|
||||
if schema_mismatch:
|
||||
logger.warning("search_vector_chunks schema mismatch, recreating vector tables")
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
|
||||
|
||||
@@ -408,11 +418,16 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
expected_dimension_sql = f"float[{self._vector_dimensions}]"
|
||||
|
||||
if vector_sql and expected_dimension_sql not in vector_sql:
|
||||
logger.warning(
|
||||
f"Embedding dimension mismatch (expected {self._vector_dimensions}), "
|
||||
"recreating search_vector_embeddings"
|
||||
)
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
|
||||
await session.execute(create_sqlite_search_vector_embeddings(self._vector_dimensions))
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"SQLite vector tables ready (dimensions={self._vector_dimensions})")
|
||||
self._vector_tables_initialized = True
|
||||
|
||||
async def _prepare_vector_session(self, session: AsyncSession) -> None:
|
||||
@@ -566,6 +581,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -581,6 +597,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ class ProjectInfoResponse(BaseModel):
|
||||
available_projects: Dict[str, Dict[str, Any]] = Field(
|
||||
description="Map of configured project names to detailed project information"
|
||||
)
|
||||
default_project: str = Field(description="Name of the default project")
|
||||
default_project: Optional[str] = Field(description="Name of the default project")
|
||||
|
||||
# Statistics
|
||||
statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
|
||||
@@ -196,7 +196,7 @@ class ProjectList(BaseModel):
|
||||
"""Response model for listing projects."""
|
||||
|
||||
projects: List[ProjectItem]
|
||||
default_project: str
|
||||
default_project: Optional[str]
|
||||
|
||||
|
||||
class ProjectStatusResponse(BaseModel):
|
||||
|
||||
@@ -47,6 +47,7 @@ class ValidationReport(BaseModel):
|
||||
|
||||
entity_type: str | None = None
|
||||
total_notes: int = 0
|
||||
total_entities: int = 0
|
||||
valid_count: int = 0
|
||||
warning_count: int = 0
|
||||
error_count: int = 0
|
||||
@@ -110,6 +111,10 @@ class DriftReport(BaseModel):
|
||||
"""Schema drift analysis comparing schema definition to actual usage."""
|
||||
|
||||
entity_type: str
|
||||
schema_found: bool = Field(
|
||||
default=True,
|
||||
description="Whether a schema was found for this type",
|
||||
)
|
||||
new_fields: list[DriftFieldResponse] = Field(
|
||||
default_factory=list,
|
||||
description="Fields common in notes but not in schema",
|
||||
|
||||
@@ -68,6 +68,7 @@ class SearchQuery(BaseModel):
|
||||
tags: Optional[List[str]] = None # Convenience tag filter
|
||||
status: Optional[str] = None # Convenience status filter
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS
|
||||
min_similarity: Optional[float] = None # Per-query override for semantic_min_similarity
|
||||
|
||||
@field_validator("after_date")
|
||||
@classmethod
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectMode
|
||||
from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectMode
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository import (
|
||||
ProjectRepository,
|
||||
@@ -174,9 +174,13 @@ async def initialize_app(
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
"""
|
||||
# Skip initialization in cloud mode - cloud manages its own projects
|
||||
if app_config.cloud_mode_enabled:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
# Trigger: database backend is Postgres (cloud deployment)
|
||||
# Why: cloud deployments manage their own projects and migrations via the cloud platform.
|
||||
# The local MCP server always uses SQLite and needs initialization even when
|
||||
# cloud_mode is enabled (for per-project cloud routing).
|
||||
# Outcome: skip initialization only for actual cloud Postgres deployments.
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
logger.info("Skipping local initialization - Postgres backend manages its own schema")
|
||||
return
|
||||
|
||||
logger.info("Initializing app...")
|
||||
@@ -186,7 +190,7 @@ async def initialize_app(
|
||||
# Reconcile projects from config.json with projects table
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
logger.info("App initialization completed (migration running in background if needed)")
|
||||
logger.info("App initialization completed")
|
||||
|
||||
|
||||
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
@@ -195,14 +199,13 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
This is a wrapper for the async initialize_app function that can be
|
||||
called from synchronous code like CLI entry points.
|
||||
|
||||
No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
|
||||
No-op if database backend is Postgres (cloud deployment manages its own schema).
|
||||
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
"""
|
||||
# Skip initialization in cloud mode - cloud manages its own projects
|
||||
if app_config.cloud_mode_enabled:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
logger.info("Skipping local initialization - Postgres backend manages its own schema")
|
||||
return
|
||||
|
||||
async def _init_and_cleanup():
|
||||
|
||||
@@ -340,7 +340,9 @@ class LinkResolver:
|
||||
if not project:
|
||||
project = await self._project_repository.get_by_name_case_insensitive(identifier)
|
||||
if not project:
|
||||
project = await self._project_repository.get_by_permalink(generate_permalink(identifier))
|
||||
project = await self._project_repository.get_by_permalink(
|
||||
generate_permalink(identifier)
|
||||
)
|
||||
|
||||
if project:
|
||||
self._project_cache_by_identifier[cache_key] = project
|
||||
|
||||
@@ -20,7 +20,13 @@ from basic_memory.schemas import (
|
||||
ProjectStatistics,
|
||||
SystemStatus,
|
||||
)
|
||||
from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
|
||||
from basic_memory.config import (
|
||||
WATCH_STATUS_JSON,
|
||||
ConfigManager,
|
||||
ProjectEntry,
|
||||
get_project_config,
|
||||
ProjectConfig,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -62,20 +68,20 @@ class ProjectService:
|
||||
return self.config_manager.projects
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
def default_project(self) -> Optional[str]:
|
||||
"""Get the name of the default project.
|
||||
|
||||
Returns:
|
||||
The name of the default project
|
||||
The name of the default project, or None if not set
|
||||
"""
|
||||
return self.config_manager.default_project
|
||||
|
||||
@property
|
||||
def current_project(self) -> str:
|
||||
def current_project(self) -> Optional[str]:
|
||||
"""Get the name of the currently active project.
|
||||
|
||||
Returns:
|
||||
The name of the current project
|
||||
The name of the current project, or None if not set
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
|
||||
|
||||
@@ -340,7 +346,9 @@ class ProjectService:
|
||||
# No default project - set the config default as default
|
||||
# This is defensive code for edge cases where no default exists
|
||||
config_default = self.config_manager.default_project # pragma: no cover
|
||||
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
|
||||
config_project = (
|
||||
await self.repository.get_by_name(config_default) if config_default else None
|
||||
) # pragma: no cover
|
||||
if config_project: # pragma: no cover
|
||||
await self.repository.set_as_default(config_project.id) # pragma: no cover
|
||||
logger.info(
|
||||
@@ -364,11 +372,12 @@ class ProjectService:
|
||||
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
||||
|
||||
# Get all projects from configuration and normalize names if needed
|
||||
config_projects = self.config_manager.projects.copy()
|
||||
updated_config = {}
|
||||
# Use .config property (not load_config()) so tests can patch ConfigManager.config
|
||||
config = self.config_manager.config
|
||||
updated_config: Dict[str, ProjectEntry] = {}
|
||||
config_updated = False
|
||||
|
||||
for name, path in config_projects.items():
|
||||
for name, entry in config.projects.items():
|
||||
# Generate normalized name (what the database expects)
|
||||
normalized_name = generate_permalink(name)
|
||||
|
||||
@@ -376,25 +385,24 @@ class ProjectService:
|
||||
logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'")
|
||||
config_updated = True
|
||||
|
||||
updated_config[normalized_name] = path
|
||||
updated_config[normalized_name] = entry
|
||||
|
||||
# Update the configuration if any changes were made
|
||||
if config_updated:
|
||||
config = self.config_manager.load_config()
|
||||
config.projects = updated_config
|
||||
self.config_manager.save_config(config)
|
||||
logger.info("Config updated with normalized project names")
|
||||
|
||||
# Use the normalized config for further processing
|
||||
config_projects = updated_config
|
||||
# Use the normalized config for further processing — keys are now project names
|
||||
config_project_names = updated_config
|
||||
|
||||
# Add projects that exist in config but not in DB
|
||||
for name, path in config_projects.items():
|
||||
for name, entry in config_project_names.items():
|
||||
if name not in db_projects_by_permalink:
|
||||
logger.info(f"Adding project '{name}' to database")
|
||||
project_data = {
|
||||
"name": name,
|
||||
"path": path,
|
||||
"path": entry.path,
|
||||
"permalink": generate_permalink(name),
|
||||
"is_active": True,
|
||||
# Don't set is_default here - let the enforcement logic handle it
|
||||
@@ -405,7 +413,7 @@ class ProjectService:
|
||||
# Config is the source of truth - if a project was deleted from config,
|
||||
# it should be deleted from DB too (fixes issue #193)
|
||||
for name, project in db_projects_by_permalink.items():
|
||||
if name not in config_projects:
|
||||
if name not in config_project_names:
|
||||
logger.info(
|
||||
f"Removing project '{name}' from database (deleted from config, source of truth)"
|
||||
)
|
||||
@@ -456,8 +464,8 @@ class ProjectService:
|
||||
|
||||
# Update in configuration
|
||||
config = self.config_manager.load_config()
|
||||
old_path = config.projects[name]
|
||||
config.projects[name] = resolved_path
|
||||
old_path = config.projects[name].path
|
||||
config.projects[name].path = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database using robust lookup
|
||||
@@ -468,7 +476,7 @@ class ProjectService:
|
||||
else:
|
||||
logger.error(f"Project '{name}' exists in config but not in database")
|
||||
# Restore the old path in config since DB update failed
|
||||
config.projects[name] = old_path
|
||||
config.projects[name].path = old_path
|
||||
self.config_manager.save_config(config)
|
||||
raise ValueError(f"Project '{name}' not found in database")
|
||||
|
||||
@@ -504,7 +512,7 @@ class ProjectService:
|
||||
|
||||
# Update in config
|
||||
config = self.config_manager.load_config()
|
||||
config.projects[name] = resolved_path
|
||||
config.projects[name].path = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database
|
||||
|
||||
@@ -135,6 +135,7 @@ class SearchService:
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=query.retrieval_mode or SearchRetrievalMode.FTS,
|
||||
min_similarity=query.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user