Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Record when a project's index pass last completed.

Everything else a readiness check can ask about a project is a count, and a
count of zero is ambiguous in the one place it matters: a project with no
pending work and a project that was never indexed both report zero. That is how
`bm status` came to report a freshly added project ready while 25 unindexed
notes sat on disk (#1414). This column carries the missing bit -- NULL means no
pass has ever completed -- so "nothing to do" and "nothing was ever started"
stop looking alike.

Existing projects are backfilled from `updated_at` when they already hold
entities: those demonstrably have an index, and leaving them NULL would newly
describe every upgraded project as never indexed. `updated_at` is a lower bound
on when indexing happened, which is all this column needs to be -- only
NULL-versus-not is load bearing.

Revision ID: w6r7e8a9d0y1
Revises: v5o6b7s8d9e0
Create Date: 2026-09-02 12:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "w6r7e8a9d0y1"
down_revision: Union[str, None] = "v5o6b7s8d9e0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add project.last_indexed_at and backfill projects that already have entities."""
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_indexed_at", sa.DateTime(timezone=True), nullable=True))

op.execute(
sa.text(
"UPDATE project SET last_indexed_at = updated_at "
"WHERE EXISTS (SELECT 1 FROM entity WHERE entity.project_id = project.id)"
)
)


def downgrade() -> None:
"""Drop the index-completion marker."""
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.drop_column("last_indexed_at")
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Record that a vector-sync pass deferred the rest of an entity's chunks.

An entity producing more chunks than one shard is processed a shard at a time:
`plan_entity_vector_shard` schedules the first `OVERSIZED_ENTITY_VECTOR_SHARD_SIZE`
and reports the entity incomplete. The chunks it did not schedule have no manifest
row at all, so after shard one the entity looks fully embedded to any query over
`search_vector_chunks` -- which let readiness report IDLE, and `bm status --wait`
return, while the note still had no semantic coverage past its first shard (#1440
review).

Nothing else in the schema records the chunks that were never written, and the
expected set can only be recomputed by re-chunking and re-hashing the entity's
content -- far too expensive for the route a waiter polls. So the sync that makes
the call records it here, and stays the only writer.

Revision ID: x7d8e9f0a1b2
Revises: w6r7e8a9d0y1
Create Date: 2026-09-03 02:45:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "x7d8e9f0a1b2"
down_revision: Union[str, None] = "w6r7e8a9d0y1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


# `entity` carries generated columns (`frontmatter_status` and friends, added by
# d7e8f9a0b1c2 as sa.Computed). SQLite's batch mode implements a column change by
# recreating the table, and that recreation emits the generated columns twice --
# "duplicate column name: frontmatter_status". A plain ALTER TABLE avoids the
# recreation entirely; SQLite has supported ADD/DROP COLUMN natively since 3.35,
# which is below the floor for the Python versions this project supports.


def upgrade() -> None:
"""Add entity.vector_sync_deferred_at, NULL meaning no deferred work."""
op.add_column(
"entity", sa.Column("vector_sync_deferred_at", sa.DateTime(timezone=True), nullable=True)
)


def downgrade() -> None:
"""Drop the deferral marker."""
op.drop_column("entity", "vector_sync_deferred_at")
12 changes: 10 additions & 2 deletions src/basic_memory/api/v2/routers/project_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ProjectConfigV2ExternalDep,
ProjectIndexCommandDep,
ProjectIndexObserverDep,
ProjectReadinessServiceDep,
ProjectExternalIdPathDep,
ReadCacheDep,
SessionDep,
Expand Down Expand Up @@ -282,17 +283,24 @@ async def index_project(
@router.post("/{project_id}/status", response_model=ProjectIndexStatusResponse)
async def get_project_status(
project_index_observer: ProjectIndexObserverDep,
project_readiness: ProjectReadinessServiceDep,
project_internal_id: ProjectExternalIdPathDep,
project_id: str = Path(..., description="Project external ID (UUID)"),
force_full: bool = Query(False, description="Accepted for compatibility; ignored"),
) -> ProjectIndexStatusResponse:
"""Observe current project-index files for a project."""
"""Observe current project-index files and readiness for a project."""
logger.info(
f"API v2 request: get_project_status for project_id={project_id} "
f"(force_full ignored={force_full})"
)
observation = await project_index_observer.observe_project(project_internal_id)
return ProjectIndexStatusResponse.from_observation(observation)
# The observation is handed to the readiness reader rather than re-derived:
# it already cost a full project walk, and a waiter polls this route.
readiness = await project_readiness.readiness_for_project_id(
project_internal_id,
observation.observed_files,
)
return ProjectIndexStatusResponse.from_observation(observation, readiness)


@router.post("/resolve", response_model=ProjectResolveResponse)
Expand Down
43 changes: 31 additions & 12 deletions src/basic_memory/cli/commands/cloud/project_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import os
import shlex
from datetime import datetime
from enum import Enum
from pathlib import Path
Expand All @@ -28,6 +29,7 @@
project_sync,
project_transfer,
)
from basic_memory.utils import shell_command
from basic_memory.cli.commands.cloud.rclone_config import (
DEFAULT_RCLONE_REMOTE,
rclone_remote_exists,
Expand Down Expand Up @@ -196,7 +198,9 @@ def _require_personal_workspace(
raise typer.Exit(1)

if workspace.workspace_type != "personal":
console.print(f"[red]{unsupported_message.format(name=name)}[/red]")
# The templates below embed `--name {name}`; quote it before rendering so a
# name with a space stays one argument in the command they print.
console.print(f"[red]{unsupported_message.format(name=shlex.quote(name))}[/red]")
raise typer.Exit(1)

return workspace
Expand Down Expand Up @@ -230,7 +234,10 @@ def _require_local_sync_path(name: str, config: BasicMemoryConfig) -> str:

if not local_sync_path or not os.path.isabs(local_sync_path):
console.print(f"[red]Error: Project '{name}' has no local sync path configured[/red]")
console.print(f"\nConfigure sync with: bm cloud sync-setup {name} ~/path/to/local")
console.print(
f"\nConfigure sync with: "
f"{shell_command('bm', 'cloud', 'sync-setup', name, '~/path/to/local')}"
)
raise typer.Exit(1)

return local_sync_path
Expand Down Expand Up @@ -644,11 +651,11 @@ def _run_directional_transfer(
# (no surprise key generation); push/pull only transfer.
# Outcome: stop with the exact setup command for this workspace.
if not rclone_remote_exists(remote_name):
setup_target = (
"" if target_workspace.is_default else f" --workspace {target_workspace.slug}"
)
setup_parts = ["bm", "cloud", "setup"]
if not target_workspace.is_default:
setup_parts += ["--workspace", target_workspace.slug]
console.print(f"[red]Workspace '{target_workspace.slug}' is not set up for sync.[/red]")
console.print(f"\nRun: bm cloud setup{setup_target}")
console.print(f"\nRun: {shell_command(*setup_parts)}")
raise typer.Exit(1)

# Get tenant info for bucket name, scoped to the resolved workspace
Expand Down Expand Up @@ -921,8 +928,13 @@ def bisync_reset(
shutil.rmtree(state_path)
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
console.print(
f" 1. Preview: "
f"{shell_command('bm', 'cloud', 'bisync', '--name', name, '--resync', '--dry-run')}"
)
console.print(
f" 2. Sync: {shell_command('bm', 'cloud', 'bisync', '--name', name, '--resync')}"
)

except Exception as e:
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
Expand Down Expand Up @@ -995,11 +1007,18 @@ async def _create_local_project():
# Lead with the Team-safe additive commands (work on any workspace); the
# `sync`/`bisync` mirrors are Personal-workspace-only.
console.print("\nNext steps:")
console.print(f" 1. Preview a pull: bm cloud pull --name {name} --dry-run")
console.print(f" 2. Fetch from cloud: bm cloud pull --name {name}")
console.print(f" 3. Upload local changes: bm cloud push --name {name}")
console.print(
f" Personal workspaces can also mirror with: bm cloud bisync --name {name} --resync"
f" 1. Preview a pull: {shell_command('bm', 'cloud', 'pull', '--name', name, '--dry-run')}"
)
console.print(
f" 2. Fetch from cloud: {shell_command('bm', 'cloud', 'pull', '--name', name)}"
)
console.print(
f" 3. Upload local changes: {shell_command('bm', 'cloud', 'push', '--name', name)}"
)
console.print(
f" Personal workspaces can also mirror with: "
f"{shell_command('bm', 'cloud', 'bisync', '--name', name, '--resync')}"
)
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/cli/commands/cloud/rclone_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
conflict_copy_name,
strategy_overwrites_dest,
)
from basic_memory.utils import shell_command
from basic_memory.config import resolve_data_dir
from basic_memory.utils import normalize_project_path

Expand Down Expand Up @@ -786,7 +787,7 @@ def project_bisync(
if not resync and not is_initialized(project.name) and not dry_run:
raise RcloneError(
f"First bisync for {project.name} requires --resync to establish baseline.\n"
f"Run: bm project bisync --name {project.name} --resync"
f"Run: {shell_command('bm', 'project', 'bisync', '--name', project.name, '--resync')}"
)

result = run(cmd, text=True)
Expand Down
7 changes: 5 additions & 2 deletions src/basic_memory/cli/commands/cloud/rclone_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import subprocess
from typing import Any, Optional, cast

from rich.markup import escape

from basic_memory.utils import shell_command
from rich.console import Console

console = Console()
Expand Down Expand Up @@ -38,7 +41,7 @@ def get_platform() -> str:
def run_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command with proper error handling."""
try:
console.print(f"[dim]Running: {' '.join(command)}[/dim]")
console.print(f"[dim]Running: {escape(shell_command(*command))}[/dim]")
result = subprocess.run(command, capture_output=True, text=True, check=check)
if result.stdout:
console.print(f"[dim]Output: {result.stdout.strip()}[/dim]")
Expand All @@ -49,7 +52,7 @@ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedP
console.print(f"[red]Error output: {e.stderr}[/red]")
raise RcloneInstallError(f"Command failed: {e}") from e
except FileNotFoundError as e:
raise RcloneInstallError(f"Command not found: {' '.join(command)}") from e
raise RcloneInstallError(f"Command not found: {shell_command(*command)}") from e


def install_rclone_macos() -> None:
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/cli/commands/cloud/upload_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import typer
from rich.console import Console

from basic_memory.utils import shell_command
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.cloud.cloud_utils import (
Expand Down Expand Up @@ -100,7 +101,7 @@ async def _upload():
console.print(
f"[red]Project '{project}' does not exist.[/red]\n"
f"[yellow]Options:[/yellow]\n"
f" 1. Create it first: bm project add {project} --cloud\n"
f" 1. Create it first: {shell_command('bm', 'project', 'add', project, '--cloud')}\n"
f" 2. Use --create-project flag to create automatically"
)
raise typer.Exit(1)
Expand Down
46 changes: 46 additions & 0 deletions src/basic_memory/cli/commands/command_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
import typer

from rich.console import Console
from rich.markup import escape

from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
from basic_memory.mcp.project_context import get_active_project
from basic_memory.utils import shell_command

console = Console()

Expand Down Expand Up @@ -97,6 +99,50 @@ async def run_project_index(
raise typer.Exit(1)


async def report_project_readiness(project: str) -> None:
"""Print the honest one-line index state for a project.

Silent emptiness was the original failure (#1414): an agent read a project
that looked finished and concluded it held nothing. A read that cannot be
trusted has to say so, so this prints the state rather than nothing. The
wording comes from `ProjectIndexReadiness.describe`, the same method
`bm status` renders, so the two cannot drift.
"""
# Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886).
from fastmcp.exceptions import ToolError

try:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
status = await ProjectClient(client).get_status(project_item.external_id)
except (ToolError, ValueError) as e:
# Trigger: readiness could not be read (project vanished, routing error).
# Why: this is a reporting courtesy after work that already succeeded.
# Outcome: say so and leave the caller's exit status alone.
console.print(f"[yellow]Could not read index status: {e}[/yellow]")
return
# This path only runs after a local index pass, so the local command is the
# one that can advance it.
summary = escape(
status.readiness.describe(
project_item.name,
index_command=shell_command("bm", "project", "index", project_item.name),
)
)
console.print(f"[dim]{escape(project_item.name)}: {summary}[/dim]")


async def index_project_and_report_readiness(project: str) -> None:
"""Index a project, then say what state that left it in.

One coroutine so the caller opens the database once for both steps:
`run_with_cleanup` shuts the engine down on exit, so a second call would pay
the reconnect and the migration check over again.
"""
await run_project_index(project, force_full=True, run_in_background=False)
await report_project_readiness(project)


async def get_project_info(project: str):
"""Get project information via API endpoint."""
# Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886).
Expand Down
Loading
Loading