Compare commits

...

15 Commits

Author SHA1 Message Date
phernandez cb0158c9ea test(mcp): cover write-note overwrite conflict integration
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 19:36:59 -05:00
Dennis Hempel 4fd9cae293 fix(mcp): resolve write_note overwrite conflicts by file_path strictly
`write_note(overwrite=True)` caught the 409 from create_entity and then
called `knowledge_client.resolve_entity(entity.permalink)` with the
default `strict=False`. In workspace-prefixed palaces the client-built
permalink omits the workspace slug, so exact permalink lookup misses
and the fuzzy fallback could pick an orphan row that shares tokens with
the canonical permalink. The update then wrote to the orphan, leaving
the canonical row stale. On the next overwrite the permalink uniqueness
check in `_resolve_schema_permalink` found duplicate rows and minted
`-1`/`-2` suffixes on the canonical entity, accumulating orphans on
every re-synthesis run.

The 409 came from a `file_service.exists(file_path)` check in
`prepare_create_entity_content`, so the file_path is the authoritative
key for the canonical row — no fuzzy matching needed. Resolve by
file_path with `strict=True`, POSIX-normalized so Windows clients send
the form the server stores.

Adds a regression test that spies on `resolve_entity` and asserts the
identifier and `strict` flag, plus checks that no `-1`/`-2` suffix is
minted under the canonical permalink.

Reported in basic-memory-bug-report Issue 1.

Signed-off-by: Dennis Hempel <uhg.dennnis@gmail.com>
2026-05-28 09:26:38 +02:00
Drew Cain ff5d872a8c chore: update version to 0.21.5 for v0.21.5 release 2026-05-26 10:58:09 -05:00
Drew Cain 96ee4eafd2 docs: add v0.21.5 changelog entry
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 10:57:55 -05:00
Drew Cain b109b7337f fix(mcp): attach local state to one workspace project row (#854)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 10:54:44 -05:00
Drew Cain 36b51b676e fix(mcp): return workspace-qualified write permalinks (#853)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 00:33:21 -05:00
Drew Cain 9af320187c fix(core): load sqlite-vec before vector table cleanup (#852)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 00:27:47 -05:00
Paul Hernandez 5a34a420c9 fix(mcp): preinitialize local ASGI database (#838)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-25 15:57:00 -05:00
Drew Cain a7e2368f9e chore: update version to 0.21.4 for v0.21.4 release 2026-05-23 14:55:49 -05:00
Sean Campbell c755127317 fix(cli): ignore CancelledError in background task done callback (#839) (#842)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:27:59 -05:00
Sean Campbell 94c04ee456 fix(mcp): restore write_note overwrite schema for external clients (#818) (#841)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:19:43 -05:00
Drew Cain d4ed02ba74 docs(core): move release process from CONTRIBUTING.md to AGENTS.md (#846)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:00:01 -05:00
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
22 changed files with 1257 additions and 90 deletions
+25
View File
@@ -253,6 +253,31 @@ async_client.set_client_factory(your_custom_factory)
See SPEC-16 for full context manager refactor details.
### Release Process
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
**Stable release:**
```
just release v0.21.3
```
The recipe runs `just lint` + `just typecheck`, then updates `__version__` in `src/basic_memory/__init__.py` and `"version"` in `server.json` (MCP registry metadata), commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the package, publishes to PyPI, creates the GitHub release with auto-generated notes, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
**Development builds:** every commit to `main` publishes a `0.21.3.dev26+468a22f`-style version to PyPI automatically via `.github/workflows/dev-release.yml`. No human action.
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
**Post-release tasks** the recipe surfaces but doesn't run:
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
- MCP Registry — `mcp-publisher publish` from the repo root
See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `changelog.md` alongside it) for the full release + post-release runbook, including the slash commands.
## BASIC MEMORY PRODUCT USAGE
### Knowledge Structure
+16
View File
@@ -1,5 +1,21 @@
# CHANGELOG
## v0.21.5 (2026-05-26)
Workspace/project routing fixes for MCP, plus a SQLite vector reindex stability fix.
### Bug Fixes
- **#854**: MCP project listing now keeps duplicate cloud project rows distinct by
workspace, so only the selected workspace row inherits local project state.
- **#853**: `write_note` returns workspace-qualified permalinks for cloud
workspace writes, allowing follow-up `memory://` reads to route back to the
correct workspace/project.
- **#852**: Full SQLite vector reindex now loads `sqlite-vec` before dropping
`vec0` virtual tables, preventing reindex crashes.
- **#838**: Local ASGI database initialization is preloaded so MCP routing can
safely enter local project contexts.
## v0.21.1 (2026-05-16)
CI-only release. No user-facing changes.
-34
View File
@@ -224,40 +224,6 @@ See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
- **Fixtures**: Use async pytest fixtures for setup and teardown
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
## Release Process
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
### Version Management
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
### Release Workflows
#### Development Builds
- Automatically published to PyPI on every commit to `main`
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
- Users install with: `pip install basic-memory --pre --force-reinstall`
#### Beta Releases
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
2. GitHub Actions automatically builds and publishes to PyPI
3. Users install with: `pip install basic-memory --pre`
#### Stable Releases
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
2. GitHub Actions automatically:
- Builds the package with version `0.13.0`
- Creates GitHub release with auto-generated notes
- Publishes to PyPI
3. Users install with: `pip install basic-memory`
### For Contributors
- No manual version bumping required
- Versions are automatically derived from git tags
- Focus on code changes, not version management
## Creating Issues
If you're planning to work on something, please create an issue first to discuss the approach. Include:
+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
+2 -2
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.21.1",
"version": "0.21.5",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.21.1",
"version": "0.21.5",
"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.21.1"
__version__ = "0.21.5"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+4
View File
@@ -447,8 +447,12 @@ class TaskScheduler(Protocol):
def _log_task_failure(completed: asyncio.Task) -> None:
if completed.cancelled():
return
try:
completed.result()
except asyncio.CancelledError:
return
except Exception as exc: # pragma: no cover
logger.exception("Background task failed", error=str(exc))
+197 -9
View File
@@ -1,13 +1,36 @@
import os
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import AsyncIterator, Callable, Optional
from asyncio import Lock
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from threading import RLock
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
import logfire
from basic_memory.config import ConfigManager, ProjectMode
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
_MISSING_STATE_VALUE = object()
@dataclass
class _PreparedLocalAsgiDatabase:
active_count: int
previous_engine: object
previous_session_maker: object
dependency_context: AbstractAsyncContextManager[LocalDatabaseState]
_prepared_local_asgi_database_lock = RLock()
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
def _force_local_mode() -> bool:
"""Check if local mode is forced via environment variable."""
@@ -34,15 +57,12 @@ def _build_timeout() -> Timeout:
)
def _asgi_client(timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client."""
# Import on first local-client use so CLI help/version paths can import
# routing helpers without constructing the full FastAPI router graph.
from basic_memory.api.app import app as fastapi_app
def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client for an already-prepared FastAPI app."""
from basic_memory.workspace_context import workspace_permalink_headers
return AsyncClient(
transport=ASGITransport(app=fastapi_app),
transport=ASGITransport(app=app),
base_url="http://test",
timeout=timeout,
# Local ASGI calls still cross the HTTP boundary, so request handlers need
@@ -51,6 +71,169 @@ def _asgi_client(timeout: Timeout) -> AsyncClient:
)
def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
"""Get the async lock that serializes first-time DB preparation for an app."""
with _prepared_local_asgi_database_lock:
prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app)
if prepare_lock is None:
prepare_lock = Lock()
_prepared_local_asgi_database_prepare_locks[app] = prepare_lock
return prepare_lock
@asynccontextmanager
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
"""Resolve database state for a local ASGI request."""
from fastapi.dependencies.utils import get_dependant, solve_dependencies
from basic_memory.deps import get_engine_factory
async def resolve_database_state(
database_state: Annotated[LocalDatabaseState, Depends(get_engine_factory)],
) -> LocalDatabaseState:
return database_state
scope: dict[str, Any] = {
"type": "http",
"asgi": {"version": "3.0"},
"method": "GET",
"scheme": "http",
"path": "/",
"raw_path": b"/",
"root_path": "",
"query_string": b"",
"headers": [],
"client": ("testclient", 50000),
"server": ("testserver", 80),
"app": app,
"path_params": {},
}
async with AsyncExitStack() as request_stack, AsyncExitStack() as function_stack:
scope["fastapi_inner_astack"] = request_stack
scope["fastapi_function_astack"] = function_stack
request = Request(scope)
dependant = get_dependant(path="/", call=resolve_database_state)
solved = await solve_dependencies(
request=request,
dependant=dependant,
dependency_overrides_provider=app,
async_exit_stack=request_stack,
embed_body_fields=False,
)
if solved.errors:
raise RuntimeError(f"Failed to resolve local ASGI database dependency: {solved.errors}")
yield await resolve_database_state(**solved.values)
def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
"""Retain an active local ASGI database preparation if one exists."""
with _prepared_local_asgi_database_lock:
active = _prepared_local_asgi_databases.get(app)
if active is None:
return False
active.active_count += 1
return True
def _install_prepared_local_asgi_database(
app: FastAPI,
database_state: LocalDatabaseState,
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
) -> None:
"""Install local ASGI database state after dependency resolution."""
with _prepared_local_asgi_database_lock:
active = _prepared_local_asgi_databases.get(app)
if active is not None:
raise RuntimeError("Local ASGI database state installed while another state is active")
previous_engine = getattr(app.state, "engine", _MISSING_STATE_VALUE)
previous_session_maker = getattr(app.state, "session_maker", _MISSING_STATE_VALUE)
engine, session_maker = database_state
app.state.engine = engine
app.state.session_maker = session_maker
_prepared_local_asgi_databases[app] = _PreparedLocalAsgiDatabase(
active_count=1,
previous_engine=previous_engine,
previous_session_maker=previous_session_maker,
dependency_context=dependency_context,
)
def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None:
"""Restore a FastAPI app.state attribute captured before local ASGI preparation."""
if previous_value is _MISSING_STATE_VALUE:
if hasattr(app.state, name):
delattr(app.state, name)
else:
setattr(app.state, name, previous_value)
def _release_prepared_local_asgi_database(
app: FastAPI,
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
"""Release local ASGI database state after a client context exits."""
with _prepared_local_asgi_database_lock:
active = _prepared_local_asgi_databases.get(app)
if active is None:
raise RuntimeError("Local ASGI database state released without a matching retain")
active.active_count -= 1
if active.active_count > 0:
return None
del _prepared_local_asgi_databases[app]
_restore_local_asgi_state_attribute(app, "engine", active.previous_engine)
_restore_local_asgi_state_attribute(
app,
"session_maker",
active.previous_session_maker,
)
return active.dependency_context
@asynccontextmanager
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
"""Initialize local ASGI database state before the first request."""
prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app)
async with prepare_lock:
if not _retain_prepared_local_asgi_database(app):
database_context = _resolve_local_asgi_database(app)
database_state = await database_context.__aenter__()
try:
_install_prepared_local_asgi_database(app, database_state, database_context)
except Exception:
await database_context.__aexit__(None, None, None)
raise
try:
yield
finally:
database_context = _release_prepared_local_asgi_database(app)
if database_context is not None:
await database_context.__aexit__(None, None, None)
@asynccontextmanager
async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]:
"""Create a local ASGI client."""
# Import on first local-client use so CLI help/version paths can import
# routing helpers without constructing the full FastAPI router graph.
from basic_memory.api.app import app as fastapi_app
# Trigger: local ASGITransport does not execute FastAPI lifespan startup.
# Why: letting request dependencies initialize Postgres can run asyncpg DDL
# under Starlette's request loop and trigger CPython's empty-ready-queue race.
# Outcome: request handling sees the same app.state database objects as API
# lifespan startup would have provided.
async with _prepared_local_asgi_database(fastapi_app):
async with _build_asgi_client(fastapi_app, timeout) as client:
yield client
async def _resolve_cloud_token(config) -> str:
"""Resolve cloud token with API key preferred, OAuth fallback."""
with logfire.span(
@@ -260,7 +443,12 @@ def create_client() -> AsyncClient:
if _force_local_mode() or not _force_cloud_mode():
logger.info("Creating ASGI client for local Basic Memory API")
return _asgi_client(timeout)
# Deprecated sync path: create_client() cannot await the local ASGI
# pre-initialization used by get_client(), so callers that need proper
# resource setup should use the async context manager instead.
from basic_memory.api.app import app as fastapi_app
return _build_asgi_client(fastapi_app, timeout)
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
config = ConfigManager().config
@@ -10,7 +10,12 @@ from typing import Literal
from fastmcp import Context
from loguru import logger
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.config import (
BasicMemoryConfig,
ConfigManager,
ProjectEntry,
has_cloud_credentials,
)
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
@@ -131,9 +136,66 @@ def _merge_projects(
return merged
def _workspace_entry_priority(entry: WorkspaceProjectEntry) -> tuple[bool, int, str, str]:
"""Prefer default/personal workspaces when duplicate project permalinks exist."""
workspace_type_rank = 0 if entry.workspace.workspace_type == "personal" else 1
return (
# False sorts before True, so the cloud/default workspace comes first.
not entry.workspace.is_default,
workspace_type_rank,
entry.workspace.name.casefold(),
entry.workspace.tenant_id,
)
def _select_attached_cloud_entry(
cloud_entries: tuple[WorkspaceProjectEntry, ...],
*,
config_entry: ProjectEntry | None,
config: BasicMemoryConfig | None,
) -> WorkspaceProjectEntry | None:
"""Choose the single cloud row that should inherit local project state."""
if not cloud_entries:
return None
preferred_workspace_ids: list[str] = []
if config_entry and config_entry.workspace_id:
preferred_workspace_ids.append(config_entry.workspace_id)
if (
config
and config.default_workspace
and config.default_workspace not in preferred_workspace_ids
):
preferred_workspace_ids.append(config.default_workspace)
# The configured default workspace can differ from the cloud-side default.
# Use the cloud default only after explicit local config preferences.
default_workspace_entry = next(
(entry for entry in cloud_entries if entry.workspace.is_default),
None,
)
if (
default_workspace_entry is not None
and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids
):
preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id)
for workspace_id in preferred_workspace_ids:
for entry in cloud_entries:
if entry.workspace.tenant_id == workspace_id:
return entry
if len(cloud_entries) == 1:
return cloud_entries[0]
return sorted(cloud_entries, key=_workspace_entry_priority)[0]
def _merge_workspace_projects(
local_list: ProjectList | None,
cloud_entries: tuple[WorkspaceProjectEntry, ...],
*,
config: BasicMemoryConfig | None = None,
) -> list[dict]:
"""Merge local projects with cloud projects from every accessible workspace."""
local_by_permalink: dict[str, ProjectItem] = {}
@@ -141,20 +203,40 @@ def _merge_workspace_projects(
for project in local_list.projects:
local_by_permalink[project.permalink] = project
config_by_permalink: dict[str, ProjectEntry] = {}
if config:
config_by_permalink = {
generate_permalink(project_name): entry
for project_name, entry in config.projects.items()
}
cloud_entries_by_permalink: dict[str, list[WorkspaceProjectEntry]] = {}
for entry in cloud_entries:
cloud_entries_by_permalink.setdefault(entry.project.permalink, []).append(entry)
attached_entry_by_permalink: dict[str, WorkspaceProjectEntry | None] = {}
for permalink in local_by_permalink:
attached_entry_by_permalink[permalink] = _select_attached_cloud_entry(
tuple(cloud_entries_by_permalink.get(permalink, ())),
config_entry=config_by_permalink.get(permalink),
config=config,
)
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
merged: list[dict] = []
for entry in sorted(
cloud_entries,
key=lambda item: (
not item.workspace.is_default,
item.workspace.workspace_type != "personal",
item.workspace.name.casefold(),
item.project.permalink,
),
key=lambda item: (*_workspace_entry_priority(item), item.project.permalink),
):
permalink = entry.project.permalink
local_proj = local_by_permalink.get(permalink)
local_proj = (
local_by_permalink.get(permalink)
# WorkspaceProjectEntry is a frozen dataclass containing Pydantic
# models, so value equality is the intended comparison here.
if attached_entry_by_permalink.get(permalink) == entry
else None
)
cloud_proj = entry.project
source = "local+cloud" if local_proj else "cloud"
local_path = local_proj.path if local_proj else None
@@ -339,7 +421,7 @@ async def list_memory_projects(
)
if cloud_entries:
merged = _merge_workspace_projects(local_list, cloud_entries)
merged = _merge_workspace_projects(local_list, cloud_entries, config=config)
else:
merged = _merge_projects(
local_list,
+34 -10
View File
@@ -1,6 +1,7 @@
"""Write note tool for Basic Memory MCP server."""
import textwrap
from pathlib import Path
from typing import Annotated, List, Union, Optional, Literal
import logfire
@@ -12,7 +13,13 @@ from basic_memory.mcp.project_context import get_project_client, add_project_met
from basic_memory.mcp.server import mcp
from fastmcp import Context
from basic_memory.schemas.base import Entity
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
from basic_memory.utils import (
build_qualified_permalink_reference,
coerce_dict,
parse_tags,
validate_project_path,
)
from basic_memory.workspace_context import current_workspace_permalink_context
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -35,11 +42,7 @@ async def write_note(
tags: list[str] | str | None = None,
note_type: str = "note",
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
# Force/replace are the file-write idioms models default to.
overwrite: Annotated[
bool | None,
Field(default=None, validation_alias=AliasChoices("overwrite", "force", "replace")),
] = None,
overwrite: bool | None = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
@@ -267,7 +270,19 @@ async def write_note(
raise ValueError(
"Entity permalink is required for updates"
) # pragma: no cover
entity_id = await knowledge_client.resolve_entity(entity.permalink)
# Resolve the conflicting entity by file_path with strict=True.
# The 409 came from a file_service.exists(file_path) check, so this
# file_path is the authoritative key for the canonical row. Resolving
# by permalink with fuzzy fallback (the previous behavior) could pick
# an orphan with a similar permalink — especially in workspace-prefixed
# palaces where the client-built permalink omits the workspace slug —
# causing the update to write to the wrong row and the next call to
# mint a -1/-2 suffix on the canonical entity.
# POSIX-normalize so Windows clients send the same form the server stores.
file_path_identifier = Path(entity.file_path).as_posix()
entity_id = await knowledge_client.resolve_entity(
file_path_identifier, strict=True
)
result = await knowledge_client.update_entity(
entity_id, entity.model_dump()
)
@@ -278,11 +293,20 @@ async def write_note(
else:
# Re-raise if it's not a conflict error
raise # pragma: no cover
response_permalink = result.permalink
workspace_context = current_workspace_permalink_context()
if response_permalink and workspace_context is not None:
response_permalink = build_qualified_permalink_reference(
active_project.permalink,
response_permalink,
workspace_permalink=workspace_context.workspace_slug,
)
summary = [
f"# {action} note",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"permalink: {response_permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
@@ -319,12 +343,12 @@ async def write_note(
# Log the response with structured data
logger.info(
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={response_permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
)
if output_format == "json":
return {
"title": result.title,
"permalink": result.permalink,
"permalink": response_permalink,
"file_path": result.file_path,
"checksum": result.checksum,
"action": action.lower(),
@@ -620,6 +620,25 @@ class SQLiteSearchRepository(SearchRepositoryBase):
)
await session.commit()
async def drop_vector_tables(self) -> None:
"""Drop SQLite vector tables on a sqlite-vec-enabled connection."""
async with db.scoped_session(self.session_maker) as session:
vector_sql_result = await session.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
)
)
vector_sql = vector_sql_result.scalar()
if vector_sql and "using vec0" in vector_sql.lower():
await self._ensure_sqlite_vec_loaded(session)
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
await session.execute(text("DROP TABLE IF EXISTS search_vector_index"))
await session.commit()
self._vector_tables_initialized = False
async def delete_stale_vector_rows(self) -> None:
"""Delete vector rows whose source entities no longer exist."""
await self._ensure_vector_tables()
+13 -9
View File
@@ -126,19 +126,23 @@ class SearchService:
async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
"""Reindex all content from database."""
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
logger.info("Starting full reindex")
# Clear and recreate search index
await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
)
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
)
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_index"), params={}
)
if isinstance(self.repository, SQLiteSearchRepository):
await self.repository.drop_vector_tables()
else:
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
)
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
)
await self.repository.execute_query(
text("DROP TABLE IF EXISTS search_vector_index"), params={}
)
await self.init_search_index()
# Reindex all entities
+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",
+33 -8
View File
@@ -310,31 +310,40 @@ async def test_write_note_accepts_directory_aliases(mcp_server, app, test_projec
@pytest.mark.asyncio
async def test_write_note_accepts_overwrite_aliases(mcp_server, app, test_project):
"""`force`/`replace` should map to `overwrite`."""
async def test_write_note_overwrite_canonical_via_mcp(mcp_server, app, test_project):
"""Canonical overwrite=True must reach the handler (#818 regression)."""
async with Client(mcp_server) as client:
# First create
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Alias Note",
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v1",
},
)
# Overwrite using `force` alias
blocked = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v2",
},
)
assert "# Error: Note already exists" in blocked.content[0].text
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Alias Note",
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v2",
"force": True, # alias for overwrite
"overwrite": True,
},
)
assert "Updated note" in result.content[0].text or "Created note" in result.content[0].text
assert "# Updated note" in result.content[0].text
# --- move_note aliases ---
@@ -538,3 +547,19 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app):
assert canonical in props, f"{tool_name}: canonical '{canonical}' missing"
for alias in must_not_have:
assert alias not in props, f"{tool_name}: alias '{alias}' leaked into schema"
# #818: AliasChoices on optional bool broke external-client JSON schema (null-only).
overwrite_schema = tools["write_note"].inputSchema["properties"]["overwrite"]
schema_types: set[str] = set()
if "type" in overwrite_schema:
raw = overwrite_schema["type"]
if isinstance(raw, str):
schema_types.add(raw)
else:
schema_types.update(raw)
for option in overwrite_schema.get("anyOf", ()):
if "type" in option:
schema_types.add(option["type"])
assert "boolean" in schema_types, (
f"write_note overwrite must expose boolean in schema, got {overwrite_schema}"
)
@@ -70,8 +70,12 @@ def team_workspace() -> WorkspaceInfo:
@pytest.fixture
def route_workspaces(app):
@contextmanager
def route(*workspaces: WorkspaceInfo):
with _workspace_routing(app, workspaces):
def route(*workspaces: WorkspaceInfo, forward_permalink_headers: bool = True):
with _workspace_routing(
app,
workspaces,
forward_permalink_headers=forward_permalink_headers,
):
yield
return route
@@ -117,7 +121,12 @@ def _save_permalink_config(
@contextmanager
def _workspace_routing(app, workspaces: Iterable[WorkspaceInfo]):
def _workspace_routing(
app,
workspaces: Iterable[WorkspaceInfo],
*,
forward_permalink_headers: bool = True,
):
"""Route MCP tool HTTP calls through an ASGI-backed cloud workspace seam."""
workspace_list = tuple(workspaces)
workspace_ids = {workspace.tenant_id for workspace in workspace_list}
@@ -128,10 +137,11 @@ def _workspace_routing(app, workspaces: Iterable[WorkspaceInfo]):
@asynccontextmanager
async def factory(workspace: str | None = None):
assert workspace is None or workspace in workspace_ids
headers = workspace_permalink_headers() if forward_permalink_headers else {}
async with HttpxAsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers=workspace_permalink_headers(),
headers=headers,
) as inner:
yield inner
@@ -461,3 +471,39 @@ async def test_team_workspace_permalink_routes_to_specific_workspace(
workspace_read = await _read_json(mcp_server, identifier=expected_permalink)
assert workspace_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_write_note_by_project_id_qualifies_permalink_when_headers_not_forwarded(
mcp_server,
test_project,
app_config,
team_workspace,
route_workspaces,
):
"""MCP writes should return self-routing IDs even if the API omits slug headers."""
_save_permalink_config(app_config, include_project=True, default_project=None)
title = "Project Id Workspace Permalink"
short_permalink = "permalink-suite/project-id-workspace-permalink"
expected_permalink = f"{team_workspace.slug}/{test_project.name}/{short_permalink}"
with route_workspaces(team_workspace, forward_permalink_headers=False):
write_payload = await _call_json(
mcp_server,
"write_note",
{
"project_id": test_project.external_id,
"title": title,
"directory": "permalink-suite",
"content": f"# {title}\n\nProject ID workspace body.",
"output_format": "json",
},
)
assert write_payload["permalink"] == expected_permalink
workspace_read = await _read_json(
mcp_server,
identifier=f"memory://{write_payload['permalink']}",
)
assert workspace_read["title"] == title
+89 -1
View File
@@ -5,14 +5,25 @@ Comprehensive tests covering all scenarios including note creation, content form
tag handling, error conditions, and edge cases from bug reports.
"""
import json
from pathlib import Path
from textwrap import dedent
from typing import Any
import pytest
from fastmcp import Client
from basic_memory.config import ConfigManager
from basic_memory.schemas.project_info import ProjectItem
from pathlib import Path
def _json_content(tool_result) -> dict[str, Any]:
"""Parse a FastMCP tool result content block into a JSON object."""
assert len(tool_result.content) == 1
assert tool_result.content[0].type == "text"
payload = json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
assert isinstance(payload, dict)
return payload
@pytest.mark.asyncio
@@ -113,6 +124,83 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
assert f"[Session: Using project '{test_project.name}']" in response_text
@pytest.mark.asyncio
async def test_write_note_overwrite_resolves_conflict_by_file_path_when_permalink_changes(
mcp_server, app, test_project, monkeypatch
):
"""Overwrite resolves the conflict by strict file path through the MCP client stack."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
captured_resolve: dict[str, Any] = {}
async def spy_resolve(self, identifier: str, *, strict: bool = False) -> str:
captured_resolve["identifier"] = identifier
captured_resolve["strict"] = strict
return await original_resolve(self, identifier, strict=strict)
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
async with Client(mcp_server) as client:
created = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Permalink Change",
"directory": "overwrite-conflicts",
"content": "# Overwrite Permalink Change\n\nOriginal body.",
"output_format": "json",
},
)
created_payload = _json_content(created)
assert created_payload["permalink"] == (
f"{test_project.name}/overwrite-conflicts/overwrite-permalink-change"
)
replacement = dedent("""
---
permalink: overwrite-conflicts/custom-overwrite-permalink
---
# Overwrite Permalink Change
Replacement body.
""").strip()
updated = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Permalink Change",
"directory": "overwrite-conflicts",
"content": replacement,
"overwrite": True,
"output_format": "json",
},
)
updated_payload = _json_content(updated)
assert updated_payload["action"] == "updated"
assert updated_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
assert updated_payload["file_path"] == "overwrite-conflicts/Overwrite Permalink Change.md"
assert captured_resolve == {
"identifier": "overwrite-conflicts/Overwrite Permalink Change.md",
"strict": True,
}
read_updated = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "overwrite-conflicts/custom-overwrite-permalink",
"output_format": "json",
},
)
read_payload = _json_content(read_updated)
assert read_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
assert "Replacement body." in read_payload["content"]
assert "Original body." not in read_payload["content"]
@pytest.mark.asyncio
async def test_write_note_tag_array(mcp_server, app, test_project):
"""Test creating a note with tag array (Issue #38 regression test)."""
+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."""
+38
View File
@@ -0,0 +1,38 @@
"""Tests for background task done-callback error handling."""
import asyncio
from unittest.mock import patch
import pytest
from basic_memory.deps.services import _log_task_failure
@pytest.mark.asyncio
async def test_log_task_failure_ignores_cancelled_task():
async def slow():
await asyncio.sleep(10)
task = asyncio.create_task(slow())
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
with patch("basic_memory.deps.services.logger.exception") as mock_exc:
_log_task_failure(task)
mock_exc.assert_not_called()
@pytest.mark.asyncio
async def test_log_task_failure_logs_real_exception():
async def boom():
raise ValueError("sync failed")
task = asyncio.create_task(boom())
with pytest.raises(ValueError):
await task
with patch("basic_memory.deps.services.logger.exception") as mock_exc:
_log_task_failure(task)
mock_exc.assert_called_once()
assert "sync failed" in str(mock_exc.call_args)
+232
View File
@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
import httpx
import pytest
@@ -16,11 +17,15 @@ from basic_memory.mcp.async_client import (
@pytest.fixture(autouse=True)
def _reset_async_client_state(monkeypatch):
async_client_module._client_factory = None
async_client_module._prepared_local_asgi_databases.clear()
async_client_module._prepared_local_asgi_database_prepare_locks.clear()
monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False)
monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False)
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
yield
async_client_module._client_factory = None
async_client_module._prepared_local_asgi_databases.clear()
async_client_module._prepared_local_asgi_database_prepare_locks.clear()
@pytest.mark.asyncio
@@ -51,6 +56,233 @@ async def test_get_client_default_uses_local_asgi_transport(config_manager):
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_client_preinitializes_local_asgi_database(config_manager, monkeypatch):
"""Local ASGI routing initializes DB state before request handling."""
from basic_memory import db
from basic_memory.api.app import app as fastapi_app
cfg = config_manager.load_config()
config_manager.save_config(cfg)
previous_engine = getattr(fastapi_app.state, "engine", None)
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
engine = object()
session_maker = object()
calls = []
async def fake_get_or_create_db(db_path):
calls.append(db_path)
return engine, session_maker
monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db)
try:
async with get_client() as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
assert calls == [cfg.database_path]
assert fastapi_app.state.engine is engine
assert fastapi_app.state.session_maker is session_maker
assert not hasattr(fastapi_app.state, "engine")
assert not hasattr(fastapi_app.state, "session_maker")
finally:
if previous_engine is None:
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.engine = previous_engine
if previous_session_maker is None:
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.session_maker = previous_session_maker
@pytest.mark.parametrize("async_override", [False, True])
@pytest.mark.asyncio
async def test_get_client_uses_existing_local_asgi_database_override(
config_manager,
async_override,
):
"""Local ASGI routing honors FastAPI test dependency overrides."""
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_engine_factory
cfg = config_manager.load_config()
config_manager.save_config(cfg)
previous_overrides = dict(fastapi_app.dependency_overrides)
previous_engine = getattr(fastapi_app.state, "engine", None)
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
engine = object()
session_maker = object()
calls = []
if async_override:
async def override_engine_factory():
calls.append("override")
return engine, session_maker
else:
def override_engine_factory():
calls.append("override")
return engine, session_maker
fastapi_app.dependency_overrides[get_engine_factory] = override_engine_factory
try:
async with get_client() as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
assert calls == ["override"]
assert fastapi_app.state.engine is engine
assert fastapi_app.state.session_maker is session_maker
assert not hasattr(fastapi_app.state, "engine")
assert not hasattr(fastapi_app.state, "session_maker")
finally:
fastapi_app.dependency_overrides = previous_overrides
if previous_engine is None:
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.engine = previous_engine
if previous_session_maker is None:
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.session_maker = previous_session_maker
@pytest.mark.asyncio
async def test_get_client_resolves_local_asgi_database_override_with_fastapi_di(
config_manager,
):
"""Local ASGI DB pre-init honors FastAPI dependency injection and cleanup."""
from fastapi import Request
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_engine_factory
cfg = config_manager.load_config()
config_manager.save_config(cfg)
previous_overrides = dict(fastapi_app.dependency_overrides)
previous_engine = getattr(fastapi_app.state, "engine", None)
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
engine = object()
session_maker = object()
calls = []
cleanup = []
async def override_engine_factory(
request: Request,
) -> AsyncIterator[tuple[object, object]]:
calls.append(request.app)
try:
yield engine, session_maker
finally:
cleanup.append("closed")
fastapi_app.dependency_overrides[get_engine_factory] = override_engine_factory
try:
async with get_client() as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
assert calls == [fastapi_app]
assert cleanup == []
assert fastapi_app.state.engine is engine
assert fastapi_app.state.session_maker is session_maker
assert cleanup == ["closed"]
assert not hasattr(fastapi_app.state, "engine")
assert not hasattr(fastapi_app.state, "session_maker")
finally:
fastapi_app.dependency_overrides = previous_overrides
if previous_engine is None:
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.engine = previous_engine
if previous_session_maker is None:
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.session_maker = previous_session_maker
@pytest.mark.asyncio
async def test_get_client_keeps_local_asgi_database_during_overlapping_contexts(
config_manager,
monkeypatch,
):
"""Local ASGI database state stays installed until the last overlapping client exits."""
from basic_memory import db
from basic_memory.api.app import app as fastapi_app
cfg = config_manager.load_config()
config_manager.save_config(cfg)
previous_engine = getattr(fastapi_app.state, "engine", None)
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
engine = object()
session_maker = object()
calls = []
async def fake_get_or_create_db(db_path):
calls.append(db_path)
return engine, session_maker
monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db)
first_context = get_client()
second_context = get_client()
first_entered = False
second_entered = False
first_exited = False
try:
first_client = await first_context.__aenter__()
first_entered = True
second_client = await second_context.__aenter__()
second_entered = True
assert isinstance(first_client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
assert isinstance(second_client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
assert calls == [cfg.database_path]
await first_context.__aexit__(None, None, None)
first_exited = True
assert fastapi_app.state.engine is engine
assert fastapi_app.state.session_maker is session_maker
await second_context.__aexit__(None, None, None)
second_entered = False
assert not hasattr(fastapi_app.state, "engine")
assert not hasattr(fastapi_app.state, "session_maker")
finally:
if second_entered:
await second_context.__aexit__(None, None, None)
if first_entered and not first_exited:
await first_context.__aexit__(None, None, None)
if previous_engine is None:
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.engine = previous_engine
if previous_session_maker is None:
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
else:
fastapi_app.state.session_maker = previous_session_maker
@pytest.mark.asyncio
async def test_get_client_explicit_cloud_uses_api_key(config_manager, monkeypatch):
cfg = config_manager.load_config()
+216 -1
View File
@@ -9,7 +9,8 @@ from sqlalchemy import select
from basic_memory import db
from basic_memory.mcp.tools import list_memory_projects, create_memory_project, delete_project
from basic_memory.mcp.tools.project_management import _merge_projects
from basic_memory.config import BasicMemoryConfig, ProjectEntry
from basic_memory.mcp.tools.project_management import _merge_projects, _merge_workspace_projects
from basic_memory.models.project import Project
from basic_memory.schemas.project_info import ProjectItem, ProjectList
@@ -909,6 +910,220 @@ def test_merge_projects_overlap():
assert merged[0]["workspace_tenant_id"] == "org-456"
def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspace(tmp_path):
"""A same-name team workspace project should stay cloud-only (#848)."""
local_path = str(tmp_path / "main")
local_main = _make_project("main", local_path, is_default=True)
local_list = _make_list([local_main], default="main")
personal_main = _make_project(
"main",
"/cloud/personal-main",
id=10,
external_id="personal-main-uuid",
)
team_main = _make_project(
"main",
"/cloud/team-main",
id=11,
external_id="team-main-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
team_ws = _make_workspace(
"team-tenant",
"Team",
workspace_type="organization",
slug="team",
)
workspace_index = _make_workspace_index(
[
(personal_ws, [personal_main]),
(team_ws, [team_main]),
]
)
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
by_qualified_name = {project["qualified_name"]: project for project in merged}
personal_project = by_qualified_name["personal/main"]
team_project = by_qualified_name["team/main"]
assert personal_project["source"] == "local+cloud"
assert personal_project["local_path"] == local_path
assert personal_project["path"] == local_path
assert team_project["source"] == "cloud"
assert team_project["local_path"] is None
assert team_project["path"] == "/cloud/team-main"
def test_merge_workspace_projects_uses_configured_workspace_for_local_state(tmp_path):
"""Per-project workspace_id should select the attached duplicate row."""
local_path = str(tmp_path / "main")
local_main = _make_project("main", local_path, is_default=True)
local_list = _make_list([local_main], default="main")
personal_main = _make_project(
"main",
"/cloud/personal-main",
id=10,
external_id="personal-main-uuid",
)
team_main = _make_project(
"main",
"/cloud/team-main",
id=11,
external_id="team-main-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
team_ws = _make_workspace(
"team-tenant",
"Team",
workspace_type="organization",
slug="team",
)
workspace_index = _make_workspace_index(
[
(personal_ws, [personal_main]),
(team_ws, [team_main]),
]
)
config = BasicMemoryConfig(
projects={
"main": ProjectEntry(
path=local_path,
workspace_id="team-tenant",
)
}
)
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
by_qualified_name = {project["qualified_name"]: project for project in merged}
personal_project = by_qualified_name["personal/main"]
team_project = by_qualified_name["team/main"]
assert personal_project["source"] == "cloud"
assert personal_project["local_path"] is None
assert personal_project["path"] == "/cloud/personal-main"
assert team_project["source"] == "local+cloud"
assert team_project["local_path"] == local_path
assert team_project["path"] == local_path
def test_merge_workspace_projects_uses_default_workspace_for_local_state(tmp_path):
"""Global default_workspace should attach local state before cloud default fallback."""
local_path = str(tmp_path / "main")
local_main = _make_project("main", local_path, is_default=True)
local_list = _make_list([local_main], default="main")
personal_main = _make_project(
"main",
"/cloud/personal-main",
id=10,
external_id="personal-main-uuid",
)
team_main = _make_project(
"main",
"/cloud/team-main",
id=11,
external_id="team-main-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
team_ws = _make_workspace(
"team-tenant",
"Team",
workspace_type="organization",
slug="team",
)
workspace_index = _make_workspace_index(
[
(personal_ws, [personal_main]),
(team_ws, [team_main]),
]
)
config = BasicMemoryConfig(
projects={"main": ProjectEntry(path=local_path)},
default_workspace="team-tenant",
)
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
by_qualified_name = {project["qualified_name"]: project for project in merged}
personal_project = by_qualified_name["personal/main"]
team_project = by_qualified_name["team/main"]
assert personal_project["source"] == "cloud"
assert personal_project["local_path"] is None
assert personal_project["path"] == "/cloud/personal-main"
assert team_project["source"] == "local+cloud"
assert team_project["local_path"] == local_path
assert team_project["path"] == local_path
def test_merge_workspace_projects_sorted_fallback_attaches_personal_workspace(tmp_path):
"""When config has no preference and no cloud default exists, use stable priority."""
local_path = str(tmp_path / "main")
local_main = _make_project("main", local_path, is_default=True)
local_list = _make_list([local_main], default="main")
personal_main = _make_project(
"main",
"/cloud/personal-main",
id=10,
external_id="personal-main-uuid",
)
team_main = _make_project(
"main",
"/cloud/team-main",
id=11,
external_id="team-main-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=False,
)
team_ws = _make_workspace(
"team-tenant",
"Team",
workspace_type="organization",
slug="team",
)
workspace_index = _make_workspace_index(
[
(team_ws, [team_main]),
(personal_ws, [personal_main]),
]
)
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
by_qualified_name = {project["qualified_name"]: project for project in merged}
personal_project = by_qualified_name["personal/main"]
team_project = by_qualified_name["team/main"]
assert personal_project["source"] == "local+cloud"
assert personal_project["local_path"] == local_path
assert personal_project["path"] == local_path
assert team_project["source"] == "cloud"
assert team_project["local_path"] is None
assert team_project["path"] == "/cloud/team-main"
# --- Workspace passthrough tests ---
+73
View File
@@ -1311,3 +1311,76 @@ class TestWriteNoteOverwriteGuard:
assert "# Created note" in result
assert f"project: {test_project.name}" in result
assert "file_path: guard/Brand New Note.md" in result
@pytest.mark.asyncio
async def test_write_note_overwrite_resolves_by_file_path_strictly(
self, app, test_project, entity_repository, monkeypatch
):
"""Regression: overwrite=True must resolve the conflicting entity by
file_path with strict=True, not by permalink with fuzzy fallback.
Bug shape: in workspace-prefixed palaces the client-built permalink
omits the workspace slug, so resolve_entity(permalink) with the default
strict=False would fall through to fuzzy search and could pick an
orphan row sharing tokens with the canonical permalink. The update
then wrote to the orphan, the canonical row stayed stale, and the
next overwrite minted a -1/-2 suffix because the permalink uniqueness
check found duplicate rows.
The 409 we catch came from a file_service.exists(file_path) check,
so file_path is the authoritative key — strict resolution against it
is safe even when permalinks are workspace-prefixed elsewhere.
"""
# Spy on the resolve_entity call to assert the identifier and strict flag.
from basic_memory.mcp.clients import knowledge as knowledge_mod
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
captured: dict[str, Any] = {}
async def spy_resolve(self, identifier, *, strict=False):
captured["identifier"] = identifier
captured["strict"] = strict
return await original_resolve(self, identifier, strict=strict)
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
# Create then overwrite the canonical note.
await write_note(
project=test_project.name,
title="Overview",
directory="features/foo",
content="# Overview\n\nVersion A",
)
canonical_permalink = f"{test_project.name}/features/foo/overview"
canonical = await entity_repository.get_by_permalink(canonical_permalink)
assert canonical is not None
canonical_id = canonical.id
result = await write_note(
project=test_project.name,
title="Overview",
directory="features/foo",
content="# Overview\n\nVersion B",
overwrite=True,
)
assert "# Updated note" in result
# The overwrite path resolved by file_path with strict=True — not by
# permalink with the default fuzzy fallback.
assert captured.get("identifier") == "features/foo/Overview.md"
assert captured.get("strict") is True
# And the canonical row was updated in place — no duplicate -1/-2 row.
canonical_after = await entity_repository.get_by_permalink(canonical_permalink)
assert canonical_after is not None
assert canonical_after.id == canonical_id
content = await read_note(canonical_permalink, project=test_project.name)
assert "Version B" in content
assert "Version A" not in content
for suffix in ("-1", "-2"):
stray = await entity_repository.get_by_permalink(f"{canonical_permalink}{suffix}")
assert stray is None, (
f"overwrite=True minted a stray '{suffix}' suffix on the canonical permalink"
)
+94
View File
@@ -5,7 +5,9 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository_base import VectorSyncBatchResult
from basic_memory.repository.semantic_errors import (
@@ -256,6 +258,98 @@ async def test_reindex_vectors_respects_embed_opt_out(search_service, monkeypatc
}
@pytest.mark.asyncio
async def test_reindex_vectors_purges_sqlite_vectors_before_sync(search_service, monkeypatch):
"""Regression for #829: stale vec0 rows must be purged before batch sync."""
repository = _sqlite_repo(search_service)
repository._semantic_enabled = True
calls: list[str] = []
monkeypatch.setattr(
search_service.entity_repository,
"find_all",
AsyncMock(return_value=[SimpleNamespace(id=42, entity_metadata={})]),
)
async def delete_stale_vector_rows():
calls.append("purge")
async def sync_entity_vectors_batch(entity_ids, progress_callback=None):
assert entity_ids == [42]
assert progress_callback is None
calls.append("sync")
return VectorSyncBatchResult(entities_total=1, entities_synced=1, entities_failed=0)
monkeypatch.setattr(repository, "delete_stale_vector_rows", delete_stale_vector_rows)
monkeypatch.setattr(search_service, "sync_entity_vectors_batch", sync_entity_vectors_batch)
stats = await search_service.reindex_vectors()
assert calls == ["purge", "sync"]
assert stats == {
"total_entities": 1,
"embedded": 1,
"skipped": 0,
"errors": 0,
}
@pytest.mark.asyncio
async def test_reindex_all_uses_sqlite_vec_aware_drop(search_service, monkeypatch):
"""Full service reindex should not drop vec0 tables through a raw connection."""
repository = _sqlite_repo(search_service)
executed_sql: list[str] = []
calls: list[str] = []
async def execute_query(query, params=None):
executed_sql.append(str(query))
async def drop_vector_tables():
calls.append("drop_vector_tables")
monkeypatch.setattr(repository, "execute_query", execute_query)
monkeypatch.setattr(repository, "drop_vector_tables", drop_vector_tables)
monkeypatch.setattr(search_service, "init_search_index", AsyncMock())
monkeypatch.setattr(search_service.entity_repository, "find_all", AsyncMock(return_value=[]))
await search_service.reindex_all()
assert calls == ["drop_vector_tables"]
assert all("search_vector_embeddings" not in sql for sql in executed_sql)
@pytest.mark.asyncio
async def test_drop_vector_tables_skips_sqlite_vec_load_for_plain_table(
search_service,
monkeypatch,
):
"""Plain compatibility tables should not require sqlite-vec to be loaded."""
repository = _sqlite_repo(search_service)
await repository.drop_vector_tables()
async with db.scoped_session(repository.session_maker) as session:
await session.execute(
text("CREATE TABLE search_vector_embeddings (rowid INTEGER PRIMARY KEY)")
)
await session.commit()
async def fail_if_loaded(_session):
raise AssertionError("plain tables should not load sqlite-vec")
monkeypatch.setattr(repository, "_ensure_sqlite_vec_loaded", fail_if_loaded)
await repository.drop_vector_tables()
async with db.scoped_session(repository.session_maker) as session:
result = await session.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
)
)
assert result.scalar() is None
@pytest.mark.asyncio
async def test_reindex_vectors_force_full_clears_project_vectors_before_resync(
search_service, monkeypatch