Compare commits

...

12 Commits

Author SHA1 Message Date
Drew Cain 40ed7129c8 chore: update version to 0.21.3 for v0.21.3 release 2026-05-23 13:46:48 -05:00
Drew Cain c4ef7abff5 test(core): isolate XDG_CONFIG_HOME so host env can't leak into tests (#845)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:34:41 -05:00
Paul Hernandez c75f45f3cf Update README.md
update cloud description to remove link to private cloud repo

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-23 12:22:18 -05:00
Drew Cain 5ae8a733ea fix(mcp): route mixed local/cloud projects correctly (#837)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-23 11:31:13 -05:00
Rafael Madriz b94ef01f82 feat(core): add XDG_CONFIG_HOME support (#844)
Signed-off-by: Rafael Madriz <rafa@rafaelmadriz.com>
2026-05-22 15:18:06 -05:00
Sean Campbell 12af7930de docs(installer): use pgvector image for Postgres compose (#840)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Drew Cain <groksrc@gmail.com>
2026-05-22 08:54:18 -05:00
phernandez 60ec6728de chore: update version to 0.21.1 for v0.21.1 release 2026-05-16 18:56:38 -05:00
phernandez a84e77f4af docs: add v0.21.1 changelog entry
CI-only point release to validate #833's inline Homebrew bump end-to-end
on a real tag push.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 18:56:30 -05:00
Paul Hernandez 6910620968 ci(installer): inline Homebrew formula bump (#833)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 18:55:25 -05:00
phernandez 214a54d740 chore: update version to 0.21.0 for v0.21.0 release 2026-05-16 17:15:53 -05:00
phernandez 5a90c7cedf docs: add v0.21.0 changelog entry
Promotes the Unreleased breaking-change note and adds the full v0.21.0
section covering ~80 commits since v0.20.3: workspace-routing fixes
across MCP/CLI/API, recent_activity ordering and search opt-in changes,
sync hardening, sqlite-vec graceful degrade, perf wins on CLI startup
and sync, and the project-delete cleanup landed in #832.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 17:15:43 -05:00
Paul Hernandez 9e3fe26a83 fix(core): purge SQLite search_index on project delete (#832)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 16:47:24 -05:00
30 changed files with 1993 additions and 180 deletions
+57 -19
View File
@@ -63,25 +63,63 @@ jobs:
# Only run for stable releases (not dev, beta, or rc versions)
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
permissions:
contents: write
actions: read
contents: read
steps:
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
# with the bearer token and expects a 302 redirect. GitHub now returns
# 303 on that endpoint when authenticated, which the action treats as a
# fatal error. Re-implementing the bump as plain git+sed keeps the same
# contract (update url + sha256, commit, push) with no third-party action.
- name: Update Homebrew formula
uses: mislav/bump-homebrew-formula-action@v4.1
with:
# Formula name in homebrew-basic-memory repo
formula-name: basic-memory
# The tap repository
homebrew-tap: basicmachines-co/homebrew-basic-memory
# Base branch of the tap repository
base-branch: main
# Download URL will be automatically constructed from the tag
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
# Commit message for the formula update
commit-message: |
{{formulaName}} {{version}}
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
env:
# Personal Access Token with repo scope for homebrew-basic-memory repo
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
REF: ${{ github.ref_name }}
REPO: ${{ github.repository }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
VERSION="${REF#v}"
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
echo "::group::Compute tarball sha256"
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
test -n "$SHA256"
echo "sha256: $SHA256"
echo "::endgroup::"
echo "::group::Clone tap"
git clone \
--depth 1 \
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
tap
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "::endgroup::"
echo "::group::Patch Formula/basic-memory.rb"
# Pipe-delimited sed because the URL contains slashes. The Formula
# only has one `url` and one `sha256` directive, so a first-match
# replacement is unambiguous. POSIX character classes ([[:space:]])
# keep this portable across BSD and GNU sed.
sed -i -E \
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
Formula/basic-memory.rb
git --no-pager diff Formula/basic-memory.rb
echo "::endgroup::"
if git diff --quiet Formula/basic-memory.rb; then
echo "Formula already at ${REF}; nothing to do."
exit 0
fi
echo "::group::Commit & push"
git add Formula/basic-memory.rb
git commit -m "basic-memory ${VERSION}
Created by ${RUN_URL}"
git push origin HEAD:main
echo "::endgroup::"
+9
View File
@@ -56,6 +56,15 @@ Run `just test-smoke` when you specifically need the MCP smoke flow.
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
### PR CI Gate
Before opening or updating a PR, run the checks that mirror the common required CI failures:
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`.
### Test Structure
- `tests/` - Unit tests for individual components (mocked, fast)
+122 -1
View File
@@ -1,6 +1,24 @@
# CHANGELOG
## Unreleased
## v0.21.1 (2026-05-16)
CI-only release. No user-facing changes.
### Maintenance
- **#833**: Replace `mislav/bump-homebrew-formula-action` with an inline
bash bump step. The action's `resolveRedirect` HEAD-requests
`api.github.com /repos/.../tarball/<ref>` expecting HTTP 302; GitHub now
returns 303 for authenticated requests on that endpoint, which broke
the v0.21.0 Homebrew job and required a manual tap bump. The inline
step does the same work (curl + sha256sum, clone tap, sed-bump
`url`/`sha256`, commit, push) without any third-party dependency.
## v0.21.0 (2026-05-16)
Workspace-aware everywhere: every MCP tool and CLI command now routes through the
same workspace/project model, the search and sync paths are noticeably faster,
and a handful of long-standing parsing and routing footguns are gone.
### Breaking Changes
@@ -11,6 +29,109 @@
- Bare list wikilinks like `- [[Target]]` now index as `links_to`.
- Prose list items like `- some other thing [[Target]]` now index as `links_to`.
- To preserve existing multi-word relation types on re-sync, quote them before upgrading.
- See **#824**.
### Features
- **#816**: `bm orphan` CLI command surfaces entities whose underlying markdown
files are gone, with a flag to clean them out.
- **#789**: Create projects directly by cloud workspace slug from MCP
(`create_memory_project(workspace=...)`).
- **#757**: Discover projects across every accessible cloud workspace in MCP's
project list — no more per-workspace blind spots.
- **#766**: MCP tools accept training-data-friendly parameter aliases
(`q`/`search`/`text` for `query`, etc.) so models reach for them naturally.
- **#776**: `bm db reset` refuses to run while a `basic-memory mcp` process is
alive, so resets can no longer corrupt an open session.
- **#791**: Search responses include result totals so pagers can stop guessing.
- **#719**: Cloud `note_content` tenant schema primitive lands on the backend.
- **#715**: `bm project add` accepts a `--visibility` flag for cloud projects.
### Bug Fixes
#### Search and recent activity
- **#832**: SQLite project deletion now sweeps `search_index`,
`search_vector_chunks`, and `search_vector_embeddings`, so a project that
reuses a recycled auto-increment id can't inherit the previous tenant's content.
- **#812**: `recent_activity` orders and filters by `updated_at`, so edits bubble
to the top instead of staying pinned to creation time.
- **#807**: Multi-project `search_notes` is opt-in (`search_all_projects=True`);
default search stays scoped to the resolved project.
- **#785**: `recent_activity` caps responses and emits an explicit truncation
footer instead of silently dropping rows.
- **#713**: Eliminated an N+1 query in search result hydration.
#### Workspace / project routing
- **#822**: `bm project list` now includes projects from every workspace, not
just the current one.
- **#813**, **#808**, **#806**, **#803**, **#801**, **#795**, **#790**, **#778**,
**#777**, **#722**, **#712**, **#704**: Workspace-qualified permalink routing
is centralized and applied consistently across `edit_note`, `delete_project`,
`build_context`, `memory://` URLs, factory-mode project listing, cloud uploads,
and the API client.
#### Sync
- **#827**: `rclone bisync` filters from `.bmignore` are preserved across syncs.
- **#815**: Watch service ignores hidden paths relative to the watched project,
not just relative to `$HOME`.
- **#814**: `scan` subprocesses no longer go through the shell, avoiding quoting
issues with paths that contain special characters.
- **#759**: Watch service stays inside `--project` scope.
- **#746**: Canonical markdown is preserved during single-file sync.
#### Parsing
- **#796**: Picoschema modifier descriptions (`field?(modifier, description)`)
parse correctly.
- **#769**: Obsidian callout blocks are skipped by the observation parser
instead of being mis-extracted as observations.
#### CLI
- **#775**: `bm project set-cloud` / `set-local` cleans up local DB state for
the affected project.
- **#773**: `bm cloud logout` clears `default_workspace`.
- **#780**: `bm cloud setup` hint points at `bm cloud sync-setup`.
- **#734**: `bm project info` shows cloud index freshness.
- **#718**: Private cloud projects display under their `display_name` instead of
raw UUID.
- **#768**: `read_note` / `view_note` drop no-op pagination params from their
signatures.
#### Stability
- **#774**: `sqlite-vec` failures during init degrade gracefully to keyword-only
search instead of crashing startup.
- **#733**: Delete-vector and cloud-sync cleanup is now consistent.
- **#702**: Race conditions in concurrent `delete_entity` are resolved.
- **#724**: `external_id` is preserved when entities are re-upserted during a
re-index.
- **#728**: Vector init no longer issues runtime `ALTER TABLE`.
- **#744**: `BASIC_MEMORY_CONFIG_DIR` is honored across remaining call sites.
- **#743**: FastEmbed cache lives under the data dir instead of `/tmp`.
- **#752**: Cloud projects report `source=cloud` in factory mode.
- Stripped null bytes from markdown content before DB insert.
- Allowed long `relation_type` values in API responses.
#### Installer
- **#772**: Docker-compose config volume mounts under the `appuser` home.
- **#695**: Bumped `brew outdated` timeout from 15s to 60s.
### Performance
- **#828**: CLI startup no longer pulls in the local ASGI FastAPI app when it
isn't needed.
- **#751**, **#726**, **#717**, **#714**, single-file/batch indexing: marked
speedups on the sync hot path; unchanged markdown is skipped entirely.
- **#731**, **#723**: Vector sync is faster on both backends, with tuned
fastembed defaults.
### Maintenance
- **#825**: Dependency refresh + security hardening.
- Updated to `fastmcp` 3.3.1.
- **#754**: Removed in-house telemetry wrappers in favor of direct `logfire`
usage.
- **#736**: `ty` is now the default typechecker.
- **#771**, **#770**, **#716**: New regression guards for vector-row cleanup,
long relation types, and recent activity hydration.
## v0.20.3 (2026-03-26)
+1 -2
View File
@@ -123,8 +123,7 @@ phone.
time — same files, same format, same wikilinks. Cancel anytime, your data
stays yours.
Built on WorkOS AuthKit, Neon Postgres, and Tigris S3. Platform source at
[basic-memory-cloud](https://github.com/basicmachines-co/basic-memory-cloud).
Built on WorkOS AuthKit, Neon Postgres, and Tigris S3.
### Pricing
+5 -2
View File
@@ -1,5 +1,8 @@
# Docker Compose configuration for Basic Memory with PostgreSQL
# Use this for local development and testing with Postgres backend
# Use this for local development and testing with Postgres backend.
#
# The Postgres backend requires the pgvector extension (semantic search).
# This image bundles pgvector; plain postgres:17 will not work for vector search.
#
# Usage:
# docker-compose -f docker-compose-postgres.yml up -d
@@ -7,7 +10,7 @@
services:
postgres:
image: postgres:17
image: pgvector/pgvector:pg17
container_name: basic-memory-postgres
environment:
# Local development/test credentials - NOT for production
+1
View File
@@ -263,6 +263,7 @@ The sqlite-vec extension is loaded per-connection. Vector tables are created laz
### Postgres (cloud)
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
+2 -2
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.20.3",
"version": "0.21.3",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.20.3",
"version": "0.21.3",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.20.3"
__version__ = "0.21.3"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -0,0 +1,86 @@
"""Remove orphaned search rows whose project was already deleted.
Revision ID: n7i8j9k0l1m2
Revises: m6h7i8j9k0l1
Create Date: 2026-05-15 18:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
revision: str = "n7i8j9k0l1m2"
down_revision: Union[str, None] = "m6h7i8j9k0l1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(connection, table_name: str) -> bool:
"""Inspector-based table check, dialect agnostic.
Trigger: SQLite creates search_index as an FTS5 virtual table at runtime
via SearchRepository.init_search_index, not through Alembic, so fresh
installs hit this migration before the table exists.
Why: a blind DELETE against a missing table fails the whole upgrade.
Outcome: callers skip the sweep when the table isn't present yet — the
runtime-created table on a fresh DB has no orphans to clean.
"""
return table_name in inspect(connection).get_table_names()
def upgrade() -> None:
"""Purge orphaned search rows left over from prior project deletions.
Trigger: project deletion on SQLite never removed the derived FTS rows,
because the FTS5 virtual table can't carry a foreign key. The leak shows
up in two shapes:
1. project_id no longer exists in `project` (deleted project, id never
reused).
2. project_id still exists but `entity_id` no longer exists in `entity`
— auto-increment handed the id to a brand-new project and the FTS
rows from the deleted predecessor masquerade as the new tenant's data.
Why: search_index.project_id is the only scope predicate the search
repository applies, so leftover rows surface under the wrong project on
every search.
Outcome: a one-time sweep deletes both shapes, from the FTS index and
from search_vector_chunks. Postgres already cascaded on FK delete, so
these statements are no-ops there.
"""
connection = op.get_bind()
if _table_exists(connection, "search_index"):
op.execute(
"""
DELETE FROM search_index
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_index
WHERE entity_id IS NOT NULL
AND entity_id NOT IN (SELECT id FROM entity)
"""
)
if _table_exists(connection, "search_vector_chunks"):
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE entity_id NOT IN (SELECT id FROM entity)
"""
)
def downgrade() -> None:
"""No-op: orphan rows cannot be reconstructed."""
pass
+8 -5
View File
@@ -21,7 +21,7 @@ from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = ".basic-memory"
DATA_DIR_NAME = "basic-memory"
CONFIG_FILE_NAME = "config.json"
WATCH_STATUS_JSON = "watch-status.json"
CONFIG_DIR_MODE = 0o700
@@ -69,15 +69,18 @@ def resolve_data_dir() -> Path:
Single source of truth for the per-user state directory. Honors
``BASIC_MEMORY_CONFIG_DIR`` so each process/worktree can isolate config
and database state; otherwise falls back to ``<user home>/.basic-memory``.
and database state; otherwise falls back to ``<user home>/.basic-memory``,
and then to ``XDG_CONFIG_HOME``.
Cross-platform: ``Path.home()`` reads ``$HOME`` on POSIX and
``%USERPROFILE%`` on Windows, so there's no need to check ``$HOME``
explicitly here.
"""
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
return Path(config_dir)
return Path.home() / DATA_DIR_NAME
if basic_memory_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
return Path(basic_memory_dir)
if xdg_config := os.getenv("XDG_CONFIG_HOME"):
return Path(xdg_config) / DATA_DIR_NAME
return Path.home() / ("." + DATA_DIR_NAME)
def default_fastembed_cache_dir() -> str:
+101 -7
View File
@@ -142,6 +142,15 @@ async def _set_cached_active_project(
await context.set_state("default_project_name", active_project.name)
async def _clear_cached_active_project(context: Optional[Context]) -> None:
"""Clear cached project metadata that may no longer match the active route."""
if not context:
return
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
"""Return the cached active workspace from context when available."""
if not context:
@@ -167,8 +176,7 @@ async def _set_cached_active_workspace(
# Why: project names are only unique inside one workspace, so a cached
# ProjectItem from the previous tenant can point at the wrong project
# Outcome: force the next validation call to resolve within the new tenant
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
await _clear_cached_active_project(context)
await context.set_state("active_workspace", active_workspace.model_dump())
@@ -525,6 +533,28 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
)
def _workspace_identifier_discovery_available(
identifier: str,
config: BasicMemoryConfig,
) -> bool:
"""Return True when an identifier is allowed to consult workspace discovery."""
if _cloud_workspace_discovery_available(config):
return True
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
)
if _explicit_routing() and _force_local_mode():
return False
return (
has_cloud_credentials(config)
and _split_workspace_identifier_segments(identifier) is not None
)
async def resolve_workspace_qualified_memory_url(
identifier: str,
context: Optional[Context] = None,
@@ -1326,11 +1356,23 @@ async def detect_project_from_identifier_prefix(
# Outcome: keep unqualified search/title input on the active/default project route.
return None
if _cloud_workspace_discovery_available(config):
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
if _workspace_identifier_discovery_available(identifier, config):
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
if workspace_resolution is not None:
return workspace_resolution.project_identifier
@@ -1345,7 +1387,7 @@ async def detect_project_from_identifier_prefix(
)
except ValueError as exc:
message = str(exc).lower()
if "not found" in message or "no accessible workspaces" in message:
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
@@ -1462,9 +1504,54 @@ async def get_project_client(
if project_id and not cloud_available:
project_mode = ProjectMode.LOCAL
# Trigger: project_id is a local external_id in a mixed local+cloud setup.
# Why: UUIDs are not local config keys, so get_project_mode() treats them as
# cloud projects. A local-first probe avoids making local UUIDs depend on
# healthy cloud workspace discovery.
# Outcome: resolve the effective UUID against local ASGI first; if it is not
# local, preserve the existing cloud workspace lookup path.
if (
project_id
and config.projects
and not factory_mode
and not explicit_cloud_routing
and project_mode == ProjectMode.CLOUD
):
try:
canonical_project_id = str(UUID(resolved_project))
except ValueError:
pass
else:
with logfire.span(
"routing.local_project_id_probe",
project_id=canonical_project_id,
):
async with get_client() as client:
try:
active_project = await get_active_project(
client,
canonical_project_id,
context,
)
except ToolError as exc:
if "not found" not in str(exc).lower():
raise
else:
route_mode = "local_asgi"
await _clear_cached_active_workspace_for_local_route(context)
with logfire.span(
"routing.client_session",
project_name=active_project.name,
route_mode=route_mode,
):
logger.debug("Using local ASGI routing for project_id")
yield client, active_project
return
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
route_mode = "factory" if factory_mode else "cloud_proxy"
active_ws: WorkspaceInfo | None = None
resolved_entry: WorkspaceProjectEntry | None = None
workspace_id: str
project_for_api = _unqualified_project_identifier(resolved_project)
@@ -1487,6 +1574,13 @@ async def get_project_client(
if active_ws is not None:
await _set_cached_active_workspace(context, active_ws)
if resolved_entry is not None:
cached_project = await _get_cached_active_project(context)
if (
cached_project is not None
and cached_project.external_id != resolved_entry.project.external_id
):
await _clear_cached_active_project(context)
with logfire.span(
"routing.client_session",
project_name=project_for_api,
+4 -3
View File
@@ -9,7 +9,7 @@ from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
@@ -368,8 +368,9 @@ async def edit_note(
config,
context=context,
)
elif _cloud_workspace_discovery_available(
config
elif _workspace_identifier_discovery_available(
identifier,
config,
) and is_workspace_qualified_plain_identifier(identifier):
detected = await detect_project_from_workspace_identifier_prefix(
identifier,
+25 -2
View File
@@ -10,8 +10,13 @@ from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
is_factory_mode,
)
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_identifier_prefix,
@@ -479,12 +484,30 @@ async def _search_all_projects(
total = 0
any_project_has_more = False
# Trigger: caller asked for an account-wide search.
# Why: project_id (external UUID) routes through the cloud v2 API path,
# which 401s on local installs because there's no JWT to present.
# Project names route through the local-ASGI path and work for both
# backends — cloud disambiguates names via the workspace/project
# qualified_name already baked into project_ref["project"].
# Outcome: forward project_id only when the same signals get_project_client
# uses to pick a cloud route are present. Mirrors the cloud_available
# composite in project_context.get_project_client (single source of
# truth for "can we route to cloud?").
config = ConfigManager().config
use_cloud_routing = (
is_factory_mode()
or (_explicit_routing() and not _force_local_mode())
or has_cloud_credentials(config)
)
for project_ref in project_refs:
recursive_project_id = project_ref["project_id"] if use_cloud_routing else None
try:
results = await search_notes(
query=query,
project=project_ref["project"],
project_id=project_ref["project_id"],
project_id=recursive_project_id,
page=1,
page_size=per_project_page_size,
search_type=search_type,
@@ -4,7 +4,9 @@ from pathlib import Path
from typing import Optional, Sequence, Union
from sqlalchemy import text
from loguru import logger
from sqlalchemy import inspect as sa_inspect, select, text
from sqlalchemy.exc import NoResultFound, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
@@ -12,6 +14,65 @@ from basic_memory.models.project import Project
from basic_memory.repository.repository import Repository
async def _load_sqlite_vec_on_session(session) -> bool:
"""Ensure the sqlite-vec extension is loaded on this session's connection.
Returns True when vec0 is available after the call. Returns False when the
extension can't be loaded on this Python build (e.g., python.org macOS or
Windows interpreters without `enable_load_extension`) every connection in
the pool shares the same interpreter, so a False here also means no
embedding row could ever have been written, and skipping the embeddings
purge is safe.
Mirrors SQLiteSearchRepository._ensure_sqlite_vec_loaded but as a free
function: we don't have a SearchRepository instance during project delete,
and the per-connection nature of extension loading means a pooled connection
routed to this session might not have vec loaded even when other
connections wrote embeddings.
"""
try:
await session.execute(text("SELECT vec_version()"))
return True
except OperationalError:
pass
try:
import sqlite_vec # type: ignore[import-not-found]
except ImportError:
logger.debug("sqlite-vec package not installed; skipping vec purge")
return False
async_connection = await session.connection()
raw_connection = await async_connection.get_raw_connection()
driver_connection = raw_connection.driver_connection
if not hasattr(driver_connection, "enable_load_extension"):
# Trigger: CPython build without sqlite extension support (#711).
# Why: load_extension is unavailable, so no connection in this pool
# can host vec0. No embeddings exist anywhere.
# Outcome: skip the embeddings purge entirely.
logger.debug(
"Skipping search_vector_embeddings purge: this Python build does "
"not support SQLite extension loading"
)
return False
try:
await driver_connection.enable_load_extension(True)
await driver_connection.load_extension(sqlite_vec.loadable_path())
await driver_connection.enable_load_extension(False)
await session.execute(text("SELECT vec_version()"))
except Exception as exc:
logger.warning(
"Failed to load sqlite-vec for project delete cleanup; "
"skipping embeddings purge: {}",
exc,
)
return False
return True
class ProjectRepository(Repository[Project]):
"""Repository for Project model.
@@ -121,6 +182,83 @@ class ProjectRepository(Repository[Project]):
return target_project
return None # pragma: no cover
async def delete(self, entity_id: int) -> bool:
"""Delete a project and its derived search rows in one transaction.
The cascade picture differs by backend:
- search_index project: Postgres has ON DELETE CASCADE FK; SQLite
stores search_index as an FTS5 virtual table and can't carry FKs,
so it needs explicit cleanup.
- search_vector_chunks project: neither backend has an FK here, so
both need an explicit DELETE.
- search_vector_embeddings search_vector_chunks: Postgres has an FK
(chunk_id REFERENCES ON DELETE CASCADE); SQLite stores embeddings
in a vec0 virtual table keyed by rowid with no cascade. On SQLite
the embeddings must be purged before the chunk rows, otherwise
`_run_vector_query` keeps returning stale vectors that crowd out
live results.
Each derived table is created lazily (search_index by
SearchRepository.init_search_index, the vector tables once semantic
search initializes), so any of them may be absent on minimal test DBs.
Inspect the connection once and skip whichever is missing.
"""
logger.debug(f"Deleting Project and search rows for project_id: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
project = result.scalars().one()
except NoResultFound:
logger.debug(f"No Project found to delete: {entity_id}")
return False
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
is_sqlite = dialect_name == "sqlite"
existing_tables = await session.run_sync(
lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names())
)
# search_index: SQLite has no FK on the FTS5 virtual table; Postgres
# cascades from the project FK, so the explicit DELETE is redundant.
if is_sqlite and "search_index" in existing_tables:
await session.execute(
text("DELETE FROM search_index WHERE project_id = :project_id"),
{"project_id": entity_id},
)
# search_vector_chunks: no FK to project on either backend, so both
# backends need this. SQLite must purge vec0 embeddings first
# (rowid pseudocolumn — Postgres uses chunk_id and would 500 here);
# Postgres' chunk_id FK CASCADE handles its embeddings cleanup when
# we delete the chunk rows below.
if "search_vector_chunks" in existing_tables:
if is_sqlite and "search_vector_embeddings" in existing_tables:
# Extension loading is per-connection. We must load vec0 on
# *this* session before the DELETE; otherwise a different
# pooled connection might have written embeddings that we'd
# silently leave behind.
if await _load_sqlite_vec_on_session(session):
await session.execute(
text(
"DELETE FROM search_vector_embeddings WHERE rowid IN ("
"SELECT id FROM search_vector_chunks "
"WHERE project_id = :project_id)"
),
{"project_id": entity_id},
)
await session.execute(
text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"),
{"project_id": entity_id},
)
await session.delete(project)
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
return True
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.
+17 -5
View File
@@ -40,17 +40,29 @@ async def detect_project_from_workspace_identifier_prefix(
return None
from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
_workspace_identifier_discovery_available,
resolve_workspace_qualified_identifier,
)
if not _cloud_workspace_discovery_available(config):
if not _workspace_identifier_discovery_available(identifier, config):
return None
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
if workspace_resolution is None:
return None
return workspace_resolution.project_identifier
+16
View File
@@ -149,6 +149,22 @@ def clean_routing_env(monkeypatch) -> None:
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
@pytest.fixture(autouse=True)
def isolate_data_dir_env(monkeypatch) -> None:
"""Keep host data-dir env vars from leaking into integration tests.
Why: GitHub Actions Ubuntu runners set ``XDG_CONFIG_HOME=/home/runner/.config``,
and ``resolve_data_dir()`` honors it ahead of ``Path.home() / ".basic-memory"``.
Without clearing it, the MCP tool process reads config.json from the host XDG
path instead of the tmp dir the ``config_manager`` fixture wrote to so
``test-project`` is missing from ``config.projects``, ``get_project_mode``
falls through to its CLOUD default (#837), and every tool call fails with
"Cloud routing requested but no credentials found."
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
POSTGRES_EPHEMERAL_TABLES = [
"search_vector_embeddings",
"search_vector_chunks",
+12 -6
View File
@@ -10,6 +10,8 @@ pytest
# Run tests against Postgres only (requires docker-compose)
docker-compose -f docker-compose-postgres.yml up -d
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest -m postgres
# Run tests against BOTH backends
@@ -54,7 +56,7 @@ database_url = None # Uses default SQLite path
# Postgres config
database_backend = DatabaseBackend.POSTGRES
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory"
```
## Running Postgres Tests
@@ -66,18 +68,22 @@ docker-compose -f docker-compose-postgres.yml up -d
```
This starts:
- Postgres 17 on port **5433** (not 5432 to avoid conflicts)
- Test database: `basic_memory_test`
- Postgres 17 with **pgvector** (`pgvector/pgvector:pg17`) on port **5433** (not 5432 to avoid conflicts)
- Database: `basic_memory`
- Credentials: `basic_memory_user` / `dev_password`
### 2. Run Postgres Tests
```bash
# Run only Postgres tests
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest -m postgres
# Run specific test with Postgres
pytest tests/test_entity_repository.py::test_create -m postgres
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest tests/repository/test_entity_repository.py::test_create -m postgres
# Skip Postgres tests (default behavior)
pytest -m "not postgres"
@@ -121,7 +127,7 @@ jobs:
# Postgres service container
services:
postgres:
image: postgres:17
image: pgvector/pgvector:pg17
env:
POSTGRES_DB: basic_memory_test
POSTGRES_USER: basic_memory_user
@@ -169,4 +175,4 @@ docker-compose -f docker-compose-postgres.yml exec postgres pg_isready -U basic_
- [ ] Add `--run-all-backends` CLI flag to run both backends in sequence
- [ ] Implement test fixtures for backend-specific features (e.g., Postgres full-text search vs SQLite FTS5)
- [ ] Add performance comparison benchmarks between backends
- [ ] Add performance comparison benchmarks between backends
+13
View File
@@ -225,6 +225,19 @@ def isolate_routing_env(monkeypatch) -> None:
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
@pytest.fixture(autouse=True)
def isolate_data_dir_env(monkeypatch) -> None:
"""Keep host data-dir env vars from leaking into tests.
Why: GitHub Actions Ubuntu runners set ``XDG_CONFIG_HOME=/home/runner/.config``,
and ``resolve_data_dir()`` honors it ahead of ``Path.home() / ".basic-memory"``.
Without clearing it, tests that monkeypatch HOME still see the host XDG path
and assertions against the tmp home directory fail.
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
@pytest_asyncio.fixture(autouse=True)
async def cleanup_global_db_after_test() -> AsyncGenerator[None, None]:
"""Close any module-level DB engine created outside fixture ownership."""
+25
View File
@@ -20,6 +20,31 @@ def mcp() -> FastMCP:
return cast(Any, mcp_server)
class ContextState:
"""Minimal FastMCP context-state stub for MCP tests."""
def __init__(self):
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
async def info(self, message: str) -> None:
self._state["info_message"] = message
def ctx(context: ContextState) -> Any:
return cast(Any, context)
@pytest.fixture
def context_state() -> ContextState:
return ContextState()
@pytest.fixture(scope="function")
def app(
app_config, project_config, engine_factory, config_manager
+464 -72
View File
@@ -11,25 +11,7 @@ from typing import Any, AsyncIterator, cast
import pytest
class _ContextState:
"""Minimal FastMCP context-state stub for unit tests."""
def __init__(self):
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
async def info(self, message: str) -> None:
self._state["info_message"] = message
def _ctx(context: _ContextState) -> Any:
return cast(Any, context)
from tests.mcp.conftest import ContextState, ctx
def _workspace(
@@ -218,7 +200,7 @@ async def test_env_constraint_overrides_default(config_manager, config_home, mon
async def test_workspace_auto_selects_single_and_caches(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
context = _ContextState()
context = ContextState()
only_workspace = _workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
@@ -236,7 +218,7 @@ async def test_workspace_auto_selects_single_and_caches(monkeypatch):
fake_get_available_workspaces,
)
resolved = await resolve_workspace_parameter(context=_ctx(context))
resolved = await resolve_workspace_parameter(context=ctx(context))
assert resolved.tenant_id == only_workspace.tenant_id
assert await context.get_state("active_workspace") == only_workspace.model_dump()
@@ -272,7 +254,7 @@ async def test_workspace_requires_user_choice_when_multiple(monkeypatch):
)
with pytest.raises(ValueError, match="Multiple workspaces are available"):
await resolve_workspace_parameter(context=_ctx(_ContextState()))
await resolve_workspace_parameter(context=ctx(ContextState()))
@pytest.mark.asyncio
@@ -416,7 +398,7 @@ async def test_workspace_type_selection_ignores_cached_workspace_for_ambiguity(m
role="owner",
),
]
context = _ContextState()
context = ContextState()
await context.set_state("active_workspace", cached_workspace.model_dump())
fetches = 0
@@ -431,7 +413,7 @@ async def test_workspace_type_selection_ignores_cached_workspace_for_ambiguity(m
)
with pytest.raises(ValueError) as exc_info:
await resolve_workspace_parameter(workspace="organization", context=_ctx(context))
await resolve_workspace_parameter(workspace="organization", context=ctx(context))
message = str(exc_info.value)
assert fetches == 1
@@ -452,7 +434,7 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
role="owner",
is_default=True,
)
context = _ContextState()
context = ContextState()
await context.set_state("active_workspace", cached_workspace.model_dump())
async def fail_if_called(context=None): # pragma: no cover
@@ -463,7 +445,7 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
fail_if_called,
)
resolved = await resolve_workspace_parameter(context=_ctx(context))
resolved = await resolve_workspace_parameter(context=ctx(context))
assert resolved.tenant_id == cached_workspace.tenant_id
@@ -476,7 +458,7 @@ async def test_workspace_project_index_caches_and_invalidates(monkeypatch):
invalidate_workspace_project_index,
)
context = _ContextState()
context = ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
@@ -513,8 +495,8 @@ async def test_workspace_project_index_caches_and_invalidates(monkeypatch):
fake_fetch_workspace_project_entries,
)
first = await _ensure_workspace_project_index(context=_ctx(context))
second = await _ensure_workspace_project_index(context=_ctx(context))
first = await _ensure_workspace_project_index(context=ctx(context))
second = await _ensure_workspace_project_index(context=ctx(context))
assert [entry.qualified_name for entry in first.entries] == [
"personal/personal-notes",
@@ -523,8 +505,8 @@ async def test_workspace_project_index_caches_and_invalidates(monkeypatch):
assert second.entries == first.entries
assert calls == ["personal", "acme"]
await invalidate_workspace_project_index(_ctx(context))
await _ensure_workspace_project_index(context=_ctx(context))
await invalidate_workspace_project_index(ctx(context))
await _ensure_workspace_project_index(context=ctx(context))
assert calls == ["personal", "acme", "personal", "acme"]
@@ -539,7 +521,7 @@ async def test_workspace_project_index_keeps_successes_when_workspace_fetch_fail
resolve_workspace_project_identifier,
)
context = _ContextState()
context = ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
@@ -572,21 +554,21 @@ async def test_workspace_project_index_keeps_successes_when_workspace_fetch_fail
fake_fetch_workspace_project_entries,
)
index = await _ensure_workspace_project_index(context=_ctx(context))
index = await _ensure_workspace_project_index(context=ctx(context))
assert [entry.qualified_name for entry in index.entries] == ["personal/meeting-notes"]
assert [workspace.slug for workspace in index.failed_workspaces] == ["acme"]
resolved = await resolve_workspace_project_identifier(
"personal/meeting-notes",
context=_ctx(context),
context=ctx(context),
)
assert resolved.project.external_id == "personal-meeting-notes"
with pytest.raises(ValueError, match="Use 'personal/meeting-notes'"):
await resolve_workspace_project_identifier(
"meeting-notes",
context=_ctx(context),
context=ctx(context),
)
@@ -800,8 +782,11 @@ async def test_detect_project_from_memory_url_prefix_skips_workspace_discovery_f
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_if_called)
# Keep this at two path segments. Three-segment memory URLs are valid
# workspace-qualified candidates in mixed local+cloud mode, so they should
# attempt workspace discovery even when a local project is configured.
resolved = await detect_project_from_memory_url_prefix(
"memory://notes/foo/bar",
"memory://notes/foo",
BasicMemoryConfig(
projects={"main": ProjectEntry(path="/tmp/main")},
cloud_api_key="bmc_test123",
@@ -811,6 +796,101 @@ async def test_detect_project_from_memory_url_prefix_skips_workspace_discovery_f
assert resolved is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("identifier", "message"),
[
("memory://docs/topic/note", "No accessible workspaces found for this account."),
(
"docs/topic/note",
"Unable to discover projects in any accessible workspace. Failed workspaces: personal",
),
],
)
async def test_detect_project_from_identifier_prefix_ignores_workspace_discovery_failures(
monkeypatch,
identifier,
message,
):
import basic_memory.mcp.project_context as project_context
from basic_memory.config import BasicMemoryConfig, ProjectEntry
from basic_memory.mcp.project_context import detect_project_from_identifier_prefix
async def fail_workspace_index(context=None):
raise ValueError(message)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_workspace_index)
resolved = await detect_project_from_identifier_prefix(
identifier,
BasicMemoryConfig(
projects={"main": ProjectEntry(path="/tmp/main")},
cloud_api_key="bmc_test123",
),
)
assert resolved is None
@pytest.mark.asyncio
async def test_detect_project_from_identifier_prefix_resolves_workspace_with_local_config(
monkeypatch,
):
import basic_memory.mcp.project_context as project_context
from basic_memory.config import BasicMemoryConfig, ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
detect_project_from_identifier_prefix,
)
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
index = _build_workspace_project_index(
(personal,),
(
WorkspaceProjectEntry(
workspace=personal,
project=_project("main", id=1, external_id="personal-main-id"),
),
),
)
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
config = BasicMemoryConfig(
projects={"hermes-memory": ProjectEntry(path="/tmp/hermes-memory")},
cloud_api_key="bmc_test123",
)
assert (
await detect_project_from_identifier_prefix(
"memory://personal/main/main-to-do-list",
config,
)
== "personal/main"
)
assert (
await detect_project_from_identifier_prefix(
"personal/main/main-to-do-list",
config,
)
== "personal/main"
)
@pytest.mark.asyncio
async def test_resolve_workspace_qualified_memory_url_ignores_workspace_project_miss(
monkeypatch,
@@ -1045,7 +1125,7 @@ async def test_resolve_workspace_project_identifier_uses_active_workspace_for_du
resolve_workspace_project_identifier,
)
context = _ContextState()
context = ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
@@ -1081,7 +1161,7 @@ async def test_resolve_workspace_project_identifier_uses_active_workspace_for_du
resolved = await resolve_workspace_project_identifier(
"meeting-notes",
context=_ctx(context),
context=ctx(context),
)
assert resolved.workspace.slug == "acme"
assert resolved.project.external_id == "acme-project-id"
@@ -1232,6 +1312,318 @@ async def test_get_project_client_with_project_id_routes_locally_without_cloud(
assert captured["validated_project"] == canonical_uuid
@pytest.mark.asyncio
async def test_get_project_client_with_local_project_id_routes_locally_with_cloud_credentials(
config_manager, monkeypatch
):
"""A local-only project_id should resolve locally before cloud workspace discovery."""
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
async def fail_index(context=None): # pragma: no cover
raise AssertionError("local project_id should not require cloud discovery")
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_client(**kwargs) -> AsyncIterator[object]:
captured["get_client_kwargs"] = kwargs
yield object()
async def fake_get_active_project(client, project, context=None, headers=None):
captured["validated_project"] = project
return _project("Hermes Memory", id=99, external_id=project)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_index)
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
local_uuid = "55555555-5555-5555-5555-555555555555"
async with project_context.get_project_client(project_id=local_uuid) as (_, active):
assert active.external_id == local_uuid
assert captured["get_client_kwargs"] == {}
assert captured["validated_project"] == local_uuid
@pytest.mark.asyncio
async def test_get_project_client_with_local_project_id_clears_cached_workspace(
config_manager, monkeypatch
):
"""Local project_id routing must not inherit a previous cloud workspace context."""
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
context = ContextState()
await context.set_state("active_workspace", personal.model_dump())
async def fail_index(context=None): # pragma: no cover
raise AssertionError("local project_id should not require cloud discovery")
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_client(**kwargs) -> AsyncIterator[object]:
captured["get_client_kwargs"] = kwargs
yield object()
async def fake_get_active_project(client, project, context=None, headers=None):
captured["validated_project"] = project
return _project("Hermes Memory", id=99, external_id=project)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_index)
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
local_uuid = "55555555-5555-5555-5555-555555555555"
async with project_context.get_project_client(
project_id=local_uuid,
context=ctx(context),
) as (_, active):
assert active.external_id == local_uuid
assert captured["get_client_kwargs"] == {}
assert captured["validated_project"] == local_uuid
assert await context.get_state("active_workspace") is None
@pytest.mark.asyncio
async def test_get_project_client_with_project_id_respects_env_constraint(
config_manager, monkeypatch
):
"""BASIC_MEMORY_MCP_PROJECT must remain authoritative when project_id is supplied."""
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
config = config_manager.load_config()
config.projects["env-project"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "env-project")
)
config.projects["other-project"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "other-project")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project")
async def fail_index(context=None): # pragma: no cover
raise AssertionError("env-constrained local project should not use cloud discovery")
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_client(**kwargs) -> AsyncIterator[object]:
captured["get_client_kwargs"] = kwargs
yield object()
async def fake_get_active_project(client, project, context=None, headers=None):
captured["validated_project"] = project
return _project(str(project), id=99, external_id="env-project-id")
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_index)
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
requested_uuid = "55555555-5555-5555-5555-555555555555"
async with project_context.get_project_client(project_id=requested_uuid) as (_, active):
assert active.name == "env-project"
assert captured["get_client_kwargs"] == {}
assert captured["validated_project"] == "env-project"
@pytest.mark.asyncio
async def test_get_project_client_with_cloud_project_id_routes_to_workspace_with_local_config(
config_manager, monkeypatch
):
"""Cloud project_id routing falls through to the workspace index after a local miss."""
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from mcp.server.fastmcp.exceptions import ToolError
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
cloud_uuid = "22222222-2222-2222-2222-222222222222"
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
cloud_project = _project("main", id=2, external_id=cloud_uuid)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=cloud_project),),
)
async def fake_index(context=None):
return index
get_client_calls: list[dict[str, str | None]] = []
validated_projects: list[str] = []
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
get_client_calls.append({"project_name": project_name, "workspace": workspace})
yield object()
async def fake_get_active_project(client, project, context=None, headers=None):
validated_projects.append(project)
if project == cloud_uuid:
raise ToolError("project not found")
return _project(project, id=cloud_project.id, external_id=cloud_project.external_id)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
async with project_context.get_project_client(project_id=cloud_uuid) as (_, active):
assert active.external_id == cloud_uuid
assert get_client_calls == [
{"project_name": None, "workspace": None},
{"project_name": "main", "workspace": "personal-tenant"},
]
assert validated_projects == [cloud_uuid, "main"]
@pytest.mark.asyncio
async def test_get_project_client_clears_stale_cached_project_for_workspace_route(
config_manager, monkeypatch
):
"""Workspace routes must not reuse a same-name project with a different UUID."""
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import WorkspaceProjectEntry
from basic_memory.schemas.project_info import ProjectItem
config = config_manager.load_config()
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
expected_uuid = "22222222-2222-2222-2222-222222222222"
expected_project = _project("main", id=2, external_id=expected_uuid)
stale_project = ProjectItem(
id=99,
external_id="33333333-3333-3333-3333-333333333333",
name="main",
path="/tmp/stale-main",
is_default=False,
)
context = ContextState()
await context.set_state("active_workspace", personal.model_dump())
await context.set_state("active_project", stale_project.model_dump())
async def fake_resolve_workspace_project_identifier(project_name, context=None):
assert project_name == "personal/main"
return WorkspaceProjectEntry(workspace=personal, project=expected_project)
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
assert project_name == "main"
assert workspace == "personal-tenant"
yield object()
class FakeResponse:
def json(self):
return {
"external_id": expected_uuid,
"project_id": expected_project.id,
"name": expected_project.name,
"permalink": expected_project.permalink,
"path": expected_project.path,
"is_active": True,
"is_default": False,
"resolution_method": "permalink",
}
calls = {"count": 0}
async def fake_call_post(*args, **kwargs):
calls["count"] += 1
return FakeResponse()
monkeypatch.setattr(
project_context,
"resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
async with project_context.get_project_client(
project="personal/main",
context=ctx(context),
) as (_, active):
assert active.external_id == expected_uuid
cached_project = await context.get_state("active_project")
assert isinstance(cached_project, dict)
assert cached_project["external_id"] == expected_uuid
assert calls["count"] == 1
@pytest.mark.asyncio
async def test_get_project_client_prefers_project_id_over_project_name(monkeypatch):
"""When both project and project_id are passed, the UUID takes precedence."""
@@ -1270,7 +1662,7 @@ async def test_resolve_project_parameter_uses_cached_active_project_before_api_d
config.default_project = None
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1288,7 +1680,7 @@ async def test_resolve_project_parameter_uses_cached_active_project_before_api_d
fail_if_called,
)
resolved = await resolve_project_parameter(project=None, context=_ctx(context))
resolved = await resolve_project_parameter(project=None, context=ctx(context))
assert resolved == cached_project.name
@@ -1302,7 +1694,7 @@ async def test_resolve_project_parameter_caches_api_default_project_name(
config.default_project = None
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
api_calls = {"count": 0}
async def fake_default_lookup():
@@ -1314,8 +1706,8 @@ async def test_resolve_project_parameter_caches_api_default_project_name(
fake_default_lookup,
)
first = await resolve_project_parameter(project=None, context=_ctx(context))
second = await resolve_project_parameter(project=None, context=_ctx(context))
first = await resolve_project_parameter(project=None, context=ctx(context))
second = await resolve_project_parameter(project=None, context=ctx(context))
assert first == "cloud-default"
assert second == "cloud-default"
@@ -1327,7 +1719,7 @@ async def test_get_active_project_uses_cached_project_before_resolution(monkeypa
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas.project_info import ProjectItem
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1345,7 +1737,7 @@ async def test_get_active_project_uses_cached_project_before_resolution(monkeypa
fail_if_called,
)
resolved = await get_active_project(client=cast(Any, None), context=_ctx(context))
resolved = await get_active_project(client=cast(Any, None), context=ctx(context))
assert resolved == cached_project
@@ -1354,7 +1746,7 @@ async def test_get_active_project_uses_cached_project_for_explicit_permalink(mon
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas.project_info import ProjectItem
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1375,7 +1767,7 @@ async def test_get_active_project_uses_cached_project_for_explicit_permalink(mon
)
resolved = await get_active_project(
client=cast(Any, None), project="my-research", context=_ctx(context)
client=cast(Any, None), project="my-research", context=ctx(context)
)
assert resolved == cached_project
@@ -1391,7 +1783,7 @@ async def test_resolve_project_and_path_uses_cached_project_for_memory_url_prefi
config.permalinks_include_project = False
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1416,7 +1808,7 @@ async def test_resolve_project_and_path_uses_cached_project_for_memory_url_prefi
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://my-research/notes/roadmap.md",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1438,7 +1830,7 @@ async def test_resolve_project_and_path_keeps_workspace_qualified_canonical_path
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1464,7 +1856,7 @@ async def test_resolve_project_and_path_keeps_workspace_qualified_canonical_path
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://team-paul/main/notes/foo",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1486,7 +1878,7 @@ async def test_resolve_project_and_path_preserves_personal_workspace_prefix(
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1513,7 +1905,7 @@ async def test_resolve_project_and_path_preserves_personal_workspace_prefix(
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://personal/main/notes/foo",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1532,7 +1924,7 @@ async def test_resolve_project_and_path_preserves_existing_project_prefixed_memo
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1545,7 +1937,7 @@ async def test_resolve_project_and_path_preserves_existing_project_prefixed_memo
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://main/notes/foo",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1568,7 +1960,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_active_route(
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1599,7 +1991,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_active_route(
client=cast(Any, None),
identifier="memory://notes/foo",
project="main",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1610,7 +2002,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_active_route(
client=cast(Any, None),
identifier="memory://main",
project="main",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1650,7 +2042,7 @@ async def test_resolve_project_and_path_uses_workspace_context_for_project_root(
client=cast(Any, None),
identifier="memory://main",
project="main",
context=_ctx(_ContextState()),
context=ctx(ContextState()),
)
assert active_project == active
@@ -1671,7 +2063,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_cached_project
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
@@ -1705,7 +2097,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_cached_project
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://main/notes/foo",
context=_ctx(context),
context=ctx(context),
)
assert active_project == cached_project
@@ -1725,7 +2117,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_resolved_proje
config.permalinks_include_project = True
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
team_workspace = _workspace(
tenant_id="team-tenant",
workspace_type="organization",
@@ -1764,7 +2156,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_resolved_proje
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://research/notes/foo",
context=_ctx(context),
context=ctx(context),
)
assert active_project.name == "Research"
@@ -1895,7 +2287,7 @@ class TestGetProjectClientRoutingOrder:
)
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
stale_workspace = _workspace(
tenant_id="team-tenant",
workspace_type="organization",
@@ -1942,13 +2334,13 @@ class TestGetProjectClientRoutingOrder:
async with get_project_client(
project="local-proj",
context=_ctx(context),
context=ctx(context),
) as (client, active_project):
_, resolved_path, is_memory_url = await resolve_project_and_path(
client=cast(Any, client),
identifier="memory://notes/foo",
project="local-proj",
context=_ctx(context),
context=ctx(context),
)
assert active_project == active
@@ -1990,7 +2382,7 @@ class TestGetProjectClientRoutingOrder:
name="Team Paul",
role="editor",
)
context = _ContextState()
context = ContextState()
await context.set_state("active_workspace", workspace.model_dump())
async def fail_resolve_workspace_parameter(workspace=None, context=None):
@@ -2034,7 +2426,7 @@ class TestGetProjectClientRoutingOrder:
async with get_project_client(
project="cloud-proj",
context=_ctx(context),
context=ctx(context),
) as (_client, active_project):
assert active_project.external_id == "cloud-project-id"
@@ -2154,7 +2546,7 @@ class TestGetProjectClientRoutingOrder:
name="Team Paul",
role="editor",
)
context = _ContextState()
context = ContextState()
await context.set_state("available_workspaces", ["ignored", workspace.model_dump()])
async def fail_workspace_provider(): # pragma: no cover
@@ -2193,7 +2585,7 @@ class TestGetProjectClientRoutingOrder:
async with get_project_client(
project="cloud-proj",
context=_ctx(context),
context=ctx(context),
) as (_client, active_project):
assert active_project.external_id == "cloud-project-id"
@@ -2335,7 +2727,7 @@ class TestGetProjectClientRoutingOrder:
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
context = _ContextState()
context = ContextState()
stale_workspace = _workspace(
tenant_id="other-tenant-id",
workspace_type="organization",
@@ -2376,7 +2768,7 @@ class TestGetProjectClientRoutingOrder:
async with get_project_client(
project="cloud-proj",
context=_ctx(context),
context=ctx(context),
) as (_client, active_project):
assert active_project.external_id == "cloud-project-id"
+3 -16
View File
@@ -4,30 +4,17 @@ from __future__ import annotations
import importlib
from contextlib import contextmanager
from typing import Any, cast
import logfire
import pytest
from basic_memory.config import ProjectEntry
from basic_memory.schemas.cloud import WorkspaceInfo
from tests.mcp.conftest import ContextState, ctx
project_context = importlib.import_module("basic_memory.mcp.project_context")
class _ContextState:
"""Minimal FastMCP context-state stub for unit tests."""
def __init__(self) -> None:
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
def _capture_spans():
spans: list[tuple[str, dict]] = []
@@ -42,7 +29,7 @@ def _capture_spans():
@pytest.mark.asyncio
async def test_resolve_workspace_parameter_emits_routing_span(monkeypatch) -> None:
spans, fake_span = _capture_spans()
context = _ContextState()
context = ContextState()
workspace = WorkspaceInfo(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
@@ -58,7 +45,7 @@ async def test_resolve_workspace_parameter_emits_routing_span(monkeypatch) -> No
monkeypatch.setattr(logfire, "span", fake_span)
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
resolved = await project_context.resolve_workspace_parameter(context=cast(Any, context))
resolved = await project_context.resolve_workspace_parameter(context=ctx(context))
assert resolved.tenant_id == workspace.tenant_id
assert spans == [
+85 -8
View File
@@ -914,8 +914,8 @@ async def test_edit_note_workspace_qualified_plain_permalink_requires_explicit_r
monkeypatch.setattr(
edit_note_module,
"_cloud_workspace_discovery_available",
lambda config: True,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
@@ -958,8 +958,8 @@ async def test_edit_note_workspace_qualified_plain_permalink_json_error(
monkeypatch.setattr(
edit_note_module,
"_cloud_workspace_discovery_available",
lambda config: True,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
@@ -1001,8 +1001,8 @@ async def test_edit_note_ambiguous_namespace_identifier_returns_guidance(
monkeypatch.setattr(
edit_note_module,
"_cloud_workspace_discovery_available",
lambda config: True,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
@@ -1054,6 +1054,83 @@ async def test_edit_note_workspace_project_args_compose_explicit_route(monkeypat
assert captured_routes == [("docs/setup", None)]
@pytest.mark.asyncio
async def test_edit_note_plain_workspace_route_returns_guidance_with_local_config(
monkeypatch,
config_manager,
test_project,
):
"""Mixed local+cloud configs should still stop ambiguous plain write routes."""
from contextlib import asynccontextmanager
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
index = _build_workspace_project_index(
(personal,),
(
WorkspaceProjectEntry(
workspace=personal,
project=ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
),
),
),
)
async def fake_index(context=None):
return index
@asynccontextmanager
async def fail_if_called(*args, **kwargs):
raise AssertionError("ambiguous plain identifiers should not select a project client")
yield
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called)
result = await edit_note(
identifier="personal/main/team/plain-edit-note",
operation="append",
content="\nAppended via plain workspace-qualified permalink.",
project=None,
)
assert isinstance(result, str)
assert "# Edit Failed - Ambiguous Identifier" in result
assert 'project="personal/main"' in result
@pytest.mark.asyncio
async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_discovery(
monkeypatch,
@@ -1077,8 +1154,8 @@ async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_
monkeypatch.setattr(
edit_note_module,
"_cloud_workspace_discovery_available",
lambda config: False,
"_workspace_identifier_discovery_available",
lambda identifier, config: False,
)
monkeypatch.setattr(
edit_note_module,
+107
View File
@@ -6,6 +6,10 @@ We keep these tests focused on path boundary/security checks, and rely on
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
import pytest
from mcp.server.fastmcp.exceptions import ToolError
@@ -157,6 +161,109 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
assert "safe note" in result["text"]
@pytest.mark.asyncio
async def test_read_content_workspace_memory_url_routes_with_local_config(
monkeypatch,
config_manager,
):
"""Workspace-qualified memory URLs should route even when local projects exist."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
read_content_module = importlib.import_module("basic_memory.mcp.tools.read_content")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=project),),
)
async def fake_index(context=None):
return index
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
assert project == "personal/main"
assert project_id is None
yield (
object(),
SimpleNamespace(
name="main",
external_id="11111111-1111-1111-1111-111111111111",
home=Path("/tmp/main"),
),
)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
assert identifier == "memory://personal/main/docs/report"
assert project == "main"
return None, "personal/main/docs/report", True
async def fake_resolve_entity_id(client, project_id, url):
assert project_id == "11111111-1111-1111-1111-111111111111"
assert url == "personal/main/docs/report"
return "entity-1"
class FakeResponse:
headers = {"content-type": "text/markdown", "content-length": "17"}
text = "# Routed Content"
content = b"# Routed Content"
async def fake_call_get(client, path, **kwargs):
assert path == "/v2/projects/11111111-1111-1111-1111-111111111111/resource/entity-1"
return FakeResponse()
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(read_content_module, "get_project_client", fake_get_project_client)
monkeypatch.setattr(
read_content_module,
"resolve_project_and_path",
fake_resolve_project_and_path,
)
monkeypatch.setattr(read_content_module, "resolve_entity_id", fake_resolve_entity_id)
monkeypatch.setattr(read_content_module, "call_get", fake_call_get)
result = await read_content(path="memory://personal/main/docs/report")
assert result == {
"type": "text",
"text": "# Routed Content",
"content_type": "text/markdown",
"encoding": "utf-8",
}
@pytest.mark.asyncio
async def test_read_content_empty_path_does_not_trigger_security_error(client, test_project):
try:
+127
View File
@@ -1,5 +1,7 @@
"""Tests for note tools that exercise the full stack with SQLite."""
from contextlib import asynccontextmanager
from types import SimpleNamespace
from textwrap import dedent
import pytest
@@ -7,6 +9,7 @@ import pytest
from basic_memory.mcp.tools import write_note, read_note
from basic_memory.mcp.tools.read_note import _parse_opening_frontmatter
from basic_memory.utils import normalize_newlines
from tests.mcp.conftest import ContextState, ctx
def test_parse_opening_frontmatter_handles_crlf():
@@ -166,6 +169,130 @@ async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch,
assert "existing note content" not in result
@pytest.mark.asyncio
async def test_read_note_explicit_workspace_project_ignores_stale_cached_project(
monkeypatch,
config_manager,
):
"""Explicit workspace routing should use the resolved cloud UUID, not stale cache."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import WorkspaceProjectEntry
from basic_memory.schemas.project_info import ProjectItem
read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
config = config_manager.load_config()
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = project_context.WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
expected_uuid = "22222222-2222-2222-2222-222222222222"
expected_project = ProjectItem(
id=2,
external_id=expected_uuid,
name="main",
path="/tmp/main",
is_default=False,
)
stale_project = ProjectItem(
id=99,
external_id="33333333-3333-3333-3333-333333333333",
name="main",
path="/tmp/stale-main",
is_default=False,
)
context = ContextState()
await context.set_state("active_workspace", personal.model_dump())
await context.set_state("active_project", stale_project.model_dump())
async def fake_resolve_workspace_project_identifier(project_name, context=None):
assert project_name == "personal/main"
return WorkspaceProjectEntry(workspace=personal, project=expected_project)
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
assert project_name == "main"
assert workspace == "personal-tenant"
yield object()
class FakeProjectResolveResponse:
def json(self):
return {
"external_id": expected_uuid,
"project_id": expected_project.id,
"name": expected_project.name,
"permalink": expected_project.permalink,
"path": expected_project.path,
"is_active": True,
"is_default": False,
"resolution_method": "permalink",
}
async def fake_call_post(*args, **kwargs):
return FakeProjectResolveResponse()
class FakeKnowledgeClient:
def __init__(self, client, project_id):
assert project_id == expected_uuid
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
assert identifier == "personal/main/todo"
return "entity-1"
async def get_entity(self, entity_id: str):
assert entity_id == "entity-1"
return SimpleNamespace(
title="TODO",
permalink="personal/main/todo",
file_path="TODO.md",
)
class FakeResourceClient:
def __init__(self, client, project_id):
assert project_id == expected_uuid
async def read(self, entity_id: str):
assert entity_id == "entity-1"
return SimpleNamespace(
status_code=200,
text="---\ntitle: TODO\n---\n\n# TODO - Priorities & Tasks\n",
)
monkeypatch.setattr(
project_context,
"resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
monkeypatch.setattr(clients_mod, "KnowledgeClient", FakeKnowledgeClient)
monkeypatch.setattr(clients_mod, "ResourceClient", FakeResourceClient)
result = await read_note_module.read_note(
"memory://todo",
project="personal/main",
output_format="json",
context=ctx(context),
)
assert isinstance(result, dict)
assert result["permalink"] == "personal/main/todo"
assert result["content"].strip() == "# TODO - Priorities & Tasks"
@pytest.mark.asyncio
async def test_note_unicode_content(app, test_project):
"""Test handling of unicode content in"""
+105
View File
@@ -3,6 +3,8 @@
import pytest
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import cast
from basic_memory.mcp.tools import write_note
from basic_memory.mcp.tools.search import (
@@ -171,6 +173,109 @@ async def test_search_memory_url_with_project_prefix(client, test_project):
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_workspace_memory_url_routes_with_local_config(monkeypatch, config_manager):
"""Workspace-qualified memory URL searches should self-route in mixed mode."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.schemas.search import SearchItemType, SearchResult
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project_item = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=project_item),),
)
async def fake_index(context=None):
return index
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
captured["project"] = project
captured["project_id"] = project_id
yield object(), SimpleNamespace(name="main", external_id=project_item.external_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
assert identifier == "memory://personal/main/tests/search-note"
assert project == "main"
return None, "personal/main/tests/search-note", True
class FakeSearchClient:
def __init__(self, client, project_id):
captured["search_project_id"] = project_id
async def search(self, payload, *, page, page_size):
captured["payload"] = payload
return SearchResponse(
results=[
SearchResult(
title="Search Note",
type=SearchItemType.ENTITY,
score=1.0,
permalink="personal/main/tests/search-note",
file_path="tests/Search Note.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", FakeSearchClient)
response = await search_notes(
query="memory://personal/main/tests/search-note",
output_format="json",
)
assert captured["project"] == "personal/main"
assert captured["project_id"] is None
assert captured["search_project_id"] == "11111111-1111-1111-1111-111111111111"
payload = cast(dict[str, object], captured["payload"])
assert payload["permalink"] == "personal/main/tests/search-note"
assert isinstance(response, dict)
assert response["results"][0]["permalink"] == "personal/main/tests/search-note"
@pytest.mark.asyncio
async def test_search_pagination(client, test_project):
"""Test basic search functionality."""
+7 -19
View File
@@ -1,12 +1,11 @@
"""Tests for workspace MCP tools."""
from typing import Any, cast
import pytest
from basic_memory.mcp.project_context import get_available_workspaces, set_workspace_provider
from basic_memory.mcp.tools.workspaces import list_workspaces
from basic_memory.schemas.cloud import WorkspaceInfo
from tests.mcp.conftest import ContextState, ctx
def _workspace(
@@ -28,17 +27,6 @@ def _workspace(
)
class _ContextState:
def __init__(self):
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
@pytest.mark.asyncio
async def test_list_workspaces_formats_workspace_rows(monkeypatch):
async def fake_get_available_workspaces(context=None):
@@ -145,7 +133,7 @@ async def test_list_workspaces_oauth_error_bubbles_up(monkeypatch):
@pytest.mark.asyncio
async def test_list_workspaces_uses_context_cache_path(monkeypatch):
context = _ContextState()
context = ContextState()
call_count = {"fetches": 0}
workspace = _workspace(
tenant_id="33333333-3333-3333-3333-333333333333",
@@ -169,8 +157,8 @@ async def test_list_workspaces_uses_context_cache_path(monkeypatch):
fake_get_available_workspaces,
)
first = await list_workspaces(context=cast(Any, context))
second = await list_workspaces(context=cast(Any, context))
first = await list_workspaces(context=ctx(context))
second = await list_workspaces(context=ctx(context))
assert "# Available Workspaces (1)" in first
assert "# Available Workspaces (1)" in second
@@ -254,14 +242,14 @@ async def test_get_available_workspaces_provider_caches_in_context():
return [workspace]
set_workspace_provider(counting_provider)
context = _ContextState()
context = ContextState()
# First call: provider is invoked, result cached
first = await get_available_workspaces(context=cast(Any, context))
first = await get_available_workspaces(context=ctx(context))
assert len(first) == 1
assert call_count["provider"] == 1
# Second call: served from context cache, provider not called again
second = await get_available_workspaces(context=cast(Any, context))
second = await get_available_workspaces(context=ctx(context))
assert len(second) == 1
assert call_count["provider"] == 1
@@ -8,8 +8,38 @@ import pytest
from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult
def _stub_routing_mode(monkeypatch, *, cloud: bool) -> None:
"""Pin the three cloud-route signals search.py reads.
`_search_all_projects` only forwards project_id (external UUID) when a
cloud route is available. The composite mirrors get_project_client:
factory mode OR explicit --cloud OR has_cloud_credentials. Tests stub
all three so a dev box with OAuth tokens on disk can't bleed into the
local-mode case.
"""
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
monkeypatch.setattr(search_mod, "is_factory_mode", lambda: False)
monkeypatch.setattr(search_mod, "_explicit_routing", lambda: cloud)
monkeypatch.setattr(search_mod, "_force_local_mode", lambda: False)
monkeypatch.setattr(search_mod, "has_cloud_credentials", lambda config: cloud)
@pytest.fixture
def cloud_routing(monkeypatch):
"""Force the cloud-routing path for multi-project search tests."""
_stub_routing_mode(monkeypatch, cloud=True)
@pytest.fixture
def local_routing(monkeypatch):
"""Force the local-routing path for multi-project search tests."""
_stub_routing_mode(monkeypatch, cloud=False)
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_qualifies_result_permalinks(monkeypatch):
async def test_search_notes_search_all_projects_qualifies_result_permalinks(
monkeypatch, cloud_routing
):
"""Multi-project search belongs to search_notes and keeps result ids routable."""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
@@ -169,7 +199,7 @@ async def test_search_notes_search_all_projects_with_no_refs_returns_empty_all_p
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_continues_after_project_failure(
monkeypatch,
monkeypatch, cloud_routing
):
"""One failing project should not discard successful all-project search results."""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
@@ -254,3 +284,85 @@ async def test_search_notes_search_all_projects_continues_after_project_failure(
assert result["total"] == 1
assert any("team-paul/main" in warning for warning in warnings)
assert any("team index unavailable" in warning for warning in warnings)
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_local_omits_project_id(
monkeypatch, local_routing
):
"""Without a cloud route, fan-out must address each project by name only.
project_id (external UUID) routes through the cloud v2 API path, which
returns 401 on local installs because there's no JWT to present. Local
fan-out has to fall back to the name-routed path so each per-project
search actually returns results instead of silently failing.
"""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
project_refs = [
{
"project": "alpha",
"project_id": "11111111-1111-1111-1111-111111111111",
},
{
"project": "beta",
"project_id": "22222222-2222-2222-2222-222222222222",
},
]
searched_projects: list[tuple[str | None, str | None]] = []
async def fake_load_search_project_refs(context=None):
return project_refs
class StubProject:
def __init__(self, name: str | None, external_id: str | None):
self.name = name or "main"
self.external_id = external_id or "local-main"
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
searched_projects.append((project, project_id))
yield object(), StubProject(project, project_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
return StubProject(project, None), identifier, False
class MockSearchClient:
def __init__(self, client, project_id):
self.project_id = project_id
async def search(self, payload, page, page_size):
return SearchResponse(
results=[
SearchResult(
title=f"Note in {self.project_id or 'local'}",
permalink="notes/example",
content="",
type=SearchItemType.ENTITY,
score=0.5,
file_path="/notes/example.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
query="anything",
search_all_projects=True,
output_format="json",
)
assert isinstance(result, dict)
assert searched_projects == [("alpha", None), ("beta", None)], (
"Local fan-out must omit project_id so the recursive search_notes calls "
"take the name-routed path."
)
assert result["total"] == 2
+104 -6
View File
@@ -139,11 +139,11 @@ async def test_workspace_identifier_project_detection_requires_workspace_shape(
"""Two-segment project-relative paths should not trigger workspace discovery."""
from basic_memory.services import link_resolver
def fail_if_called(config):
def fail_if_called(identifier, config):
raise AssertionError("plain project-relative paths should skip cloud discovery")
monkeypatch.setattr(
"basic_memory.mcp.project_context._cloud_workspace_discovery_available",
"basic_memory.mcp.project_context._workspace_identifier_discovery_available",
fail_if_called,
)
@@ -164,8 +164,8 @@ async def test_workspace_identifier_project_detection_skips_without_discovery(
from basic_memory.services import link_resolver
monkeypatch.setattr(
"basic_memory.mcp.project_context._cloud_workspace_discovery_available",
lambda config: False,
"basic_memory.mcp.project_context._workspace_identifier_discovery_available",
lambda identifier, config: False,
)
detected = await link_resolver.detect_project_from_workspace_identifier_prefix(
@@ -189,8 +189,8 @@ async def test_workspace_identifier_project_detection_returns_project(
return SimpleNamespace(project_identifier="team-acme/research")
monkeypatch.setattr(
"basic_memory.mcp.project_context._cloud_workspace_discovery_available",
lambda config: True,
"basic_memory.mcp.project_context._workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_qualified_identifier",
@@ -205,6 +205,104 @@ async def test_workspace_identifier_project_detection_returns_project(
assert detected == "team-acme/research"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"message",
[
"Workspace 'team-acme' was not found.",
"No accessible workspaces found for this account.",
"Unable to discover projects in any accessible workspace. Failed workspaces: team-acme",
],
)
async def test_workspace_identifier_project_detection_ignores_discovery_failures(
monkeypatch,
config_manager,
message,
):
"""Workspace detection should fall back when cloud discovery is unavailable."""
from basic_memory.services import link_resolver
async def fail_workspace_identifier(identifier, context=None):
raise ValueError(message)
monkeypatch.setattr(
"basic_memory.mcp.project_context._workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_qualified_identifier",
fail_workspace_identifier,
)
detected = await link_resolver.detect_project_from_workspace_identifier_prefix(
"team-acme/research/note",
config_manager.config,
)
assert detected is None
@pytest.mark.asyncio
async def test_workspace_identifier_project_detection_allows_mixed_local_cloud_config(
monkeypatch,
config_manager,
):
"""Plain workspace routes should be discoverable even with local projects configured."""
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.services import link_resolver
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
workspace = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(workspace,),
(WorkspaceProjectEntry(workspace=workspace, project=project),),
)
async def fake_index(context=None):
return index
monkeypatch.setattr(
"basic_memory.mcp.project_context._ensure_workspace_project_index",
fake_index,
)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
detected = await link_resolver.detect_project_from_workspace_identifier_prefix(
"personal/main/todo",
config_manager.config,
)
assert detected == "personal/main"
@pytest.mark.asyncio
async def test_exact_permalink_match(link_resolver, test_entities, project_prefix):
"""Test resolving a link that exactly matches a permalink."""
+214
View File
@@ -6,7 +6,9 @@ from datetime import timezone, datetime
from pathlib import Path
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.services.project_service import ProjectService
@@ -136,3 +138,215 @@ async def test_remove_project_with_related_entities(project_service: ProjectServ
project = await project_service.get_project(test_project_name)
if project:
await project_service.repository.delete(project.id)
async def _table_exists(session_maker, table: str) -> bool:
"""Return True if the named table is present on the current connection."""
from sqlalchemy import inspect as sa_inspect
async with db.scoped_session(session_maker) as session:
return await session.run_sync(
lambda sync_session: table in sa_inspect(sync_session.connection()).get_table_names()
)
@pytest.mark.asyncio
async def test_remove_project_purges_search_rows(project_service: ProjectService):
"""Project deletion must sweep the derived search tables.
SQLite stores search_index as an FTS5 virtual table, which cannot carry a
foreign key, so without an explicit purge the FTS rows survive the project
and leak into the next project that reuses the same auto-increment id.
Postgres has the cascade FK, but we expect the same end-state on either
backend. This test fails on the pre-fix code: search_index still holds the
project's rows after remove_project completes.
"""
test_project_name = f"test-search-cleanup-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_project_path = str(Path(temp_dir) / "test-search-cleanup")
os.makedirs(test_project_path, exist_ok=True)
await project_service.add_project(test_project_name, test_project_path)
project = await project_service.get_project(test_project_name)
assert project is not None
project_id = project.id
# Seed both derived tables directly. The bug is in the cleanup path,
# not the indexer, so a synthetic row is enough to prove the sweep.
async with db.scoped_session(project_service.repository.session_maker) as session:
await session.execute(
text(
"INSERT INTO search_index "
"(id, title, content_stems, content_snippet, permalink, "
" file_path, type, project_id) "
"VALUES (:id, :title, :stems, :snippet, :permalink, "
" :file_path, :type, :project_id)"
),
{
"id": 999_001,
"title": "leak canary",
"stems": "leak canary",
"snippet": "leak canary",
"permalink": f"leak-canary-{project_id}",
"file_path": "leak-canary.md",
"type": "entity",
"project_id": project_id,
},
)
await session.execute(
text(
"INSERT INTO search_vector_chunks "
"(entity_id, project_id, chunk_key, chunk_text, source_hash, "
" entity_fingerprint, embedding_model) "
"VALUES (:entity_id, :project_id, :chunk_key, :chunk_text, "
" :source_hash, :entity_fingerprint, :embedding_model)"
),
{
"entity_id": 999_001,
"project_id": project_id,
"chunk_key": "canary",
"chunk_text": "leak canary",
"source_hash": "abc",
"entity_fingerprint": "",
"embedding_model": "",
},
)
async with db.scoped_session(project_service.repository.session_maker) as session:
pre_index = (
await session.execute(
text("SELECT COUNT(*) FROM search_index WHERE project_id = :pid"),
{"pid": project_id},
)
).scalar_one()
pre_chunks = (
await session.execute(
text("SELECT COUNT(*) FROM search_vector_chunks WHERE project_id = :pid"),
{"pid": project_id},
)
).scalar_one()
assert pre_index >= 1, "seed row should exist before removal"
assert pre_chunks >= 1, "seed chunk should exist before removal"
await project_service.remove_project(test_project_name)
async with db.scoped_session(project_service.repository.session_maker) as session:
post_index = (
await session.execute(
text("SELECT COUNT(*) FROM search_index WHERE project_id = :pid"),
{"pid": project_id},
)
).scalar_one()
post_chunks = (
await session.execute(
text("SELECT COUNT(*) FROM search_vector_chunks WHERE project_id = :pid"),
{"pid": project_id},
)
).scalar_one()
assert post_index == 0, (
f"search_index still has {post_index} rows for deleted project_id={project_id} "
"— project deletion did not sweep the FTS table."
)
assert post_chunks == 0, (
f"search_vector_chunks still has {post_chunks} rows for deleted "
f"project_id={project_id}."
)
@pytest.mark.asyncio
async def test_delete_returns_false_for_missing_project_id(project_service: ProjectService):
"""ProjectRepository.delete must return False when the project id is gone.
The override loses the base Repository.delete contract if the NoResultFound
branch isn't covered — a silent True would mislead callers into thinking
a non-existent project was removed.
"""
result = await project_service.repository.delete(9_999_999)
assert result is False
@pytest.mark.asyncio
async def test_remove_project_purges_vector_embeddings(project_service: ProjectService):
"""Project deletion must also drop sqlite-vec embeddings keyed by chunk rowid.
sqlite-vec stores vectors in a vec0 virtual table that has no cascade
behavior. If embeddings linger after the chunks they reference are gone,
`_run_vector_query` pulls them as top-k candidates and crowds out live
results. The test only runs when the embeddings table is present, which
matches the install path that exercises semantic search.
"""
test_project_name = f"test-vec-cleanup-{os.urandom(4).hex()}"
session_maker = project_service.repository.session_maker
# The embeddings table only exists once semantic search has initialized.
# Skipping when it's absent keeps this test honest on minimal CI DBs.
if not await _table_exists(session_maker, "search_vector_embeddings"):
pytest.skip("search_vector_embeddings is not present on this connection")
with tempfile.TemporaryDirectory() as temp_dir:
test_project_path = str(Path(temp_dir) / "test-vec-cleanup")
os.makedirs(test_project_path, exist_ok=True)
await project_service.add_project(test_project_name, test_project_path)
project = await project_service.get_project(test_project_name)
assert project is not None
project_id = project.id
async with db.scoped_session(session_maker) as session:
await session.execute(
text(
"INSERT INTO search_vector_chunks "
"(id, entity_id, project_id, chunk_key, chunk_text, source_hash, "
" entity_fingerprint, embedding_model) "
"VALUES (:id, :entity_id, :project_id, :chunk_key, :chunk_text, "
" :source_hash, :entity_fingerprint, :embedding_model)"
),
{
"id": 999_201,
"entity_id": 999_201,
"project_id": project_id,
"chunk_key": "vec-canary",
"chunk_text": "vec canary",
"source_hash": "abc",
"entity_fingerprint": "",
"embedding_model": "",
},
)
# vec0 requires a vector matching the configured dimensions, but the
# delete path filters by rowid; a non-existing dimension would block
# this seed step. Skip the insert if the embeddings DDL hasn't run.
try:
await session.execute(
text(
"INSERT INTO search_vector_embeddings (rowid, embedding) "
"VALUES (:rowid, :embedding)"
),
{"rowid": 999_201, "embedding": "[" + ",".join(["0.0"] * 384) + "]"},
)
except Exception:
pytest.skip("search_vector_embeddings rejected the synthetic seed row")
async with db.scoped_session(session_maker) as session:
pre = (
await session.execute(
text("SELECT COUNT(*) FROM search_vector_embeddings WHERE rowid = :rowid"),
{"rowid": 999_201},
)
).scalar_one()
assert pre >= 1, "seed embedding should exist before removal"
await project_service.remove_project(test_project_name)
async with db.scoped_session(session_maker) as session:
post = (
await session.execute(
text("SELECT COUNT(*) FROM search_vector_embeddings WHERE rowid = :rowid"),
{"rowid": 999_201},
)
).scalar_one()
assert post == 0, (
f"search_vector_embeddings still has {post} rows for rowid 999_201 "
"— project deletion did not sweep the sqlite-vec embeddings table."
)
+19 -1
View File
@@ -308,8 +308,9 @@ class TestDataDirHelpers:
"""Module-level helpers that resolve the Basic Memory data directory."""
def test_resolve_data_dir_defaults_to_home_dot_basic_memory(self, config_home, monkeypatch):
"""Without BASIC_MEMORY_CONFIG_DIR, resolver returns ~/.basic-memory."""
"""Without BASIC_MEMORY_CONFIG_DIR and XDG_CONFIG_HOME, resolver returns ~/.basic-memory."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
assert resolve_data_dir() == config_home / ".basic-memory"
@@ -320,6 +321,23 @@ class TestDataDirHelpers:
assert resolve_data_dir() == custom
def test_resolve_data_dir_honors_xdg_config_home(self, tmp_path, monkeypatch):
"""XDG_CONFIG_HOME is honored when BASIC_MEMORY_CONFIG_DIR is not set."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
xdg_config = tmp_path / "xdg-config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config))
assert resolve_data_dir() == xdg_config / "basic-memory"
def test_basic_memory_config_dir_takes_precedence_over_xdg(self, tmp_path, monkeypatch):
"""BASIC_MEMORY_CONFIG_DIR takes precedence over XDG_CONFIG_HOME."""
xdg_config = tmp_path / "xdg-config"
custom = tmp_path / "custom-config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config))
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom))
assert resolve_data_dir() == custom
def test_default_fastembed_cache_dir_uses_data_dir(self, config_home, monkeypatch):
"""Default cache path is a subdir of the Basic Memory data dir."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)