Compare commits

..

32 Commits

Author SHA1 Message Date
phernandez 2c30922e65 docs(core): add Basic Machines agent style guidance
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 15:37:43 -05:00
phernandez e8cda55ddb test(cli): make copyto path assertions Windows-portable
The Windows SQLite unit job failed because two new copyto tests asserted on
raw POSIX dest strings. On Windows `str(Path("/tmp/research"))` renders with
backslashes, so the copyto dest is `\tmp\research/notes/...` (rclone accepts the
mixed separators — the product is fine). Compare the local dest via `Path(...)`
like the other tests, which normalizes separators cross-platform.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 13:00:48 -05:00
phernandez c850450ddf fix(cli): address push/pull review feedback (fatal-error guard, UX, tests)
From the Codex and claude-review automated reviews on PR #917:

- project_diff: fail fast on fatal `rclone check` errors. A non-zero exit with
  no combined listing (auth/network/missing-remote) previously produced an empty
  plan, so the transfer ran as a no-op and reported success. Now raises
  RcloneError with rclone's stderr. (Codex P2 / claude-review #2)
- sync-setup "Next steps": stop pointing every user at the now-Personal-only
  `bm cloud sync`; lead with the Team-safe `pull`/`push` and note bisync is
  Personal-only. (claude-review #1)
- Cosmetic: capitalize the push/pull abort headlines for consistency; document
  the two ConflictStrategy definitions (Typer enum vs engine Literal). (nits)
- Tests: cover keep-local+pull and keep-cloud+push (preserve-destination cases),
  project_transfer aborting on a mid-loop conflict-copy failure, and project_diff
  raising on a fatal check error vs not raising on a normal differences exit.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:30:20 -05:00
phernandez ad1f8621f1 docs(cli): document cloud push/pull and Personal-only sync/bisync
Update docs/cloud-cli.md for the docs site:
- Add a Team Workspaces push/pull section (additive, git-style transfers,
  the --on-conflict {fail|keep-local|keep-cloud|keep-both} resolution, and the
  no-baseline limitations tracked by #862).
- Mark `sync` and `bisync` as Personal-workspace-only mirrors and point Team
  users at push/pull, with a Personal-vs-Team overview table.
- Fix stale command namespace: `bm project sync|bisync|check|bisync-reset` ->
  `bm cloud ...` (these live under `bm cloud`, not `bm project`).
- Add push/pull to the command reference and the summary workflows.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:25:22 -05:00
phernandez a7937d4066 fix(cli): compare by checksum on overwrite push/pull to match conflict detection
Second code-review finding (PLAUSIBLE): project_diff detects conflicts with
`rclone check` (content/hash), but project_copy used `rclone copy` with its
default size+modtime comparison. On an overwrite strategy (keep-cloud on pull /
keep-local on push), copy could skip a file the diff had flagged as a conflict
when sizes matched and the destination was not older — silently ignoring the
user's explicit choice.

Add `--checksum` to the overwrite copy so the transfer decision uses the same
content basis as detection. New-only mode is unaffected: `--ignore-existing`
skips by existence, so the comparison basis is irrelevant there. project_sync
(the Personal-only mirror) is untouched and keeps its default comparison.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:48:20 -05:00
phernandez 9dbe313193 fix(cli): add --local-no-preallocate to conflict-copy copyto
Code-review finding: project_copy_file built its `rclone copyto` command
inline and omitted `--local-no-preallocate`. On a `pull --on-conflict keep-both`
that copyto writes the conflict copy to the local filesystem, which is exactly
the case the flag guards (NUL byte padding on virtual filesystems such as
Google Drive File Stream). Every other transfer path adds it via
_build_transfer_cmd; bring copyto in line.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:44:20 -05:00
phernandez e23363606a feat(cli): add Team-safe cloud push/pull and gate sync to Personal workspaces
Adds `bm cloud push` and `bm cloud pull` as git-style, fail-safe transfer
primitives that are usable on Team workspaces (issue #858), and restricts the
destructive `bm cloud sync` mirror to Personal workspaces.

Why
- `bm cloud sync` is a destructive local->cloud mirror; on a shared Team bucket
  it can delete a teammate's files. The only pull path was two-way `bisync`,
  which is already Personal-only (#849).
- Teams need a safe way to fetch teammates' notes and add their own without one
  stale local tree becoming authoritative for shared cloud state.

What
- push = `rclone copy` local->cloud, pull = `rclone copy` cloud->local. Both are
  additive (never delete on the destination), so neither can damage shared state.
- Conflicts (a file that differs on both sides) abort by default and list the
  paths, like git refusing to clobber local changes / rejecting a stale push.
  `--on-conflict {fail|keep-local|keep-cloud|keep-both}` lets the user decide;
  no vague --force, no silent winner.
- `sync` now requires a Personal workspace; its guard and the bisync guard point
  Team users at push/pull.

Limitations (surfaced in --help and command output, tracked by #862):
- No sync baseline yet, so deletions are not propagated and every divergence is
  treated as a conflict rather than auto-resolved. Real three-way merge needs
  the per-client manifest + Tigris snapshot baseline designed in #862.

Implementation
- rclone_commands.py: shared `_build_transfer_cmd`/`_transfer_endpoints`;
  refactor `project_sync` onto them (no behavior change); add `project_diff`
  (conflict detection via `rclone check --combined`), `project_copy`,
  `project_copy_file`, and `project_transfer` (strategy dispatch).
- project_sync.py: new `push`/`pull` commands (ungated, Team-safe) with
  `--on-conflict`/`--dry-run`; gate `sync` to Personal via a per-command guard
  message.
- Tests at the rclone-argv and CLI-command levels; existing sync/bisync tests
  updated for the new gating and messages.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:35:13 -05:00
Paul Hernandez a8d034b940 fix(mcp): point move mismatch guidance at landing path (#916)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 10:42:38 -05:00
Paul Hernandez 7c0937f658 fix(mcp): validate navigation pagination and fix recent_activity project display (#915)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:03:00 -05:00
Paul Hernandez 8570d96bad fix(cli): align bm tool commands with MCP (error exits, overwrite, category, default) (#913)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:56:43 -05:00
Paul Hernandez 480a2d9468 fix(mcp): resolve memory:// in move_note and stop false cross-project rejections (#914)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:08 -05:00
Paul Hernandez df7452e3ba fix(core): make note_types search filter case-insensitive (#912)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:04 -05:00
Paul Hernandez 85a8e59d0f fix(core): split comma-separated tags in parse_tags (#911)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:01 -05:00
Paul Hernandez 07cb7a606b docs(core): mark LiteLLM provider experimental (#899)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:13:54 -05:00
Paul Hernandez 0ef03edde6 feat(cli): add --type to the write-note tool command (#907)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:06:44 -05:00
Paul Hernandez efe43a10ea feat(cli): add --wait and --timeout to bm status (#906)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:58:02 -05:00
Paul Hernandez 4fe6fe09c8 feat(core): add observation category filter to search (#908)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:57:19 -05:00
Paul Hernandez 8acdb49a41 fix(core): reuse a single embedding provider per process (#903)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:07 -05:00
Paul Hernandez f7304bf553 feat(cli): improve workspace and cloud bisync command discoverability (#905)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:18:17 -05:00
Paul Hernandez 5b034f081d fix(core): self-heal corrupt FastEmbed model cache (#900)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:30 -05:00
Paul Hernandez 271c883ea8 fix(core): load sqlite-vec for embedding-status query (#901)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:27 -05:00
Paul Hernandez 816ee85fb9 fix(core): prevent asyncpg engine-dispose crash on Postgres backend (#902)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:23 -05:00
Paul Hernandez 3ba3a9504d fix(mcp): stop move_note reporting false success across boundaries (#904)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:20 -05:00
Paul Hernandez 1667cdc000 test(ci): normalize setup overwrite assertion (#898)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 23:20:06 -05:00
Sourish Chakraborty f916662ff3 fix(core): allow cross-project context traversal 2026-06-06 21:43:21 -05:00
DoubleDeeRuffy 0c9800cd3b fix(sync): use strict deferred relation resolution 2026-06-06 21:43:09 -05:00
Adit Karode 476239d878 feat(cli): add delete-note tool command 2026-06-06 21:41:46 -05:00
DoubleDeeRuffy 20bb19f4cd fix(mcp): resolve write-note overwrite conflicts 2026-06-06 21:41:36 -05:00
tk f6565b9d23 fix(core): L2-normalize FastEmbed vectors (#843)
L2-normalizes FastEmbed output vectors at the provider boundary so SQLite vector scoring keeps its unit-vector contract for custom FastEmbed models such as multilingual MiniLM variants.

Zero vectors are preserved as-is to avoid division errors, and the provider tests cover both non-unit vectors and zero-vector behavior.

Verification:
- uv run pytest tests/repository/test_fastembed_provider.py -q
- uv run ruff check src/basic_memory/repository/fastembed_provider.py tests/repository/test_fastembed_provider.py
- uv run ruff format --check src/basic_memory/repository/fastembed_provider.py tests/repository/test_fastembed_provider.py

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 17:15:19 -05:00
Aarish Alam b6e8c636ce feat(core): add LiteLLM embedding provider (#809)
Adds LiteLLM as a semantic embedding provider, including provider configuration, vector normalization, live-provider evaluation tooling, and documentation for OpenAI, Cohere, Azure Foundry, Azure OpenAI, and NVIDIA NIM-style cases.

Maintainer follow-up on this PR added provider hardening, asymmetric document/query embedding support, dimension-forwarding controls, SQLite/Postgres vector invalidation coverage, and the repeatable live LiteLLM harness.

Verification:
- Full base-repo Tests workflow passed for 3d4e092ceb: https://github.com/basicmachines-co/basic-memory/actions/runs/27072071785
- Live LiteLLM harness passed locally for OpenAI, Cohere, and Azure Foundry.

Co-authored-by: Aarish Alam <arishalam121@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: RheagalFire <arishalam121@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 17:13:09 -05:00
Paul Hernandez 442fd1523c fix(plugins): include codex in shared version bump (#897)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-05 18:34:57 -05:00
Paul Hernandez ce4b5d47a0 fix(skills): reject invalid frontmatter YAML (#896)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-05 13:14:27 -05:00
109 changed files with 9608 additions and 272 deletions
@@ -0,0 +1,62 @@
---
name: basic-machines-review
description: Use when reviewing Basic Machines code for house style, architecture risk, pre-merge hardening, or whether a change fits basic-memory/basic-memory-cloud conventions.
license: MIT
---
# Basic Machines Review
Use this skill for repo-local review passes where ordinary code review needs Basic Machines
house style and architecture judgment. Report findings only; do not edit code unless the user
asks you to fix specific findings.
## Scope
Review the current diff or named files against:
- The repo's `AGENTS.md` / `CLAUDE.md`
- `docs/ENGINEERING_STYLE.md`
- The touched code paths and tests
For `basic-memory`, prioritize local-first file/database/MCP boundaries. For
`basic-memory-cloud`, prioritize tenant/workspace isolation, cloud worker behavior, and
web-v2 state/runtime boundaries.
## Review Rubric
Report only concrete, falsifiable risks:
- **Cognitive load:** Is the change harder to understand than the problem requires?
- **Change propagation:** Will one product change force edits across unrelated layers?
- **Knowledge duplication:** Is the same rule encoded in multiple places that can drift?
- **Accidental complexity:** Did the change add abstractions, fallbacks, or state without need?
- **Dependency direction:** Are API/MCP/CLI, services, repositories, and UI stores respecting
their intended boundaries?
- **Domain model distortion:** Do names and types still match the product concept, or did a
transport/storage detail leak into the domain?
- **Test oracle quality:** Would the tests fail for the bug or regression the change claims to
protect against?
## House Rules To Check Explicitly
- No speculative `getattr(obj, "attr", default)` for unknown model shapes.
- No broad exception swallowing, warning-only failure paths, or hidden fallback behavior.
- No casts or `Any` that hide an unclear type relationship.
- Dataclasses for internal value/result objects; Pydantic at validation/serialization
boundaries.
- Narrow `Protocol`s when only a capability is needed.
- Explicit async/resource ownership, cancellation, and cleanup.
- Meaningful regression tests or verification for risky changes.
- Comments explain why, not what.
## Reporting Format
Lead with findings ordered by severity. Each finding should include:
```text
severity | file:line | risk category | claim
Why: concrete behavior or code path that proves the risk.
Fix: smallest practical change, or "none obvious" if the risk needs product input.
```
If there are no findings, say so and note any verification gaps that remain.
+4 -3
View File
@@ -30,7 +30,8 @@ The justfile target handles:
- ✅ Beta version format validation (supports b1, b2, rc1, etc.)
- ✅ Git status and branch checks
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update in `src/basic_memory/__init__.py`
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
- ✅ Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Beta release workflow trigger
@@ -90,6 +91,6 @@ Monitor release: https://github.com/basicmachines-co/basic-memory/actions
- Beta releases are pre-releases for testing new features
- Automatically published to PyPI with pre-release flag
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Version is automatically updated across all consolidated manifests via `just set-version`
- Ideal for validating changes before stable release
- Supports both beta (b1, b2) and release candidate (rc1, rc2) versions
- Supports both beta (b1, b2) and release candidate (rc1, rc2) versions
+6 -4
View File
@@ -42,7 +42,8 @@ The justfile target handles:
- ✅ Version format validation
- ✅ Git status and branch checks
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update across all consolidated manifests via `just set-version` (Python package + Claude Code plugin/marketplaces + Hermes + OpenClaw)
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
- ✅ Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Release workflow trigger (automatic on tag push)
@@ -194,12 +195,13 @@ Users can now upgrade:
- Version is automatically updated across **all** consolidated manifests via
`just set-version <version>` (which calls `scripts/update_versions.py`): the
Python package (`__init__.py`, `server.json`) **and** the plugin/agent artifacts
(Claude Code `plugin.json` + root/local marketplaces, Hermes `plugin.yaml` +
`__init__.py`, OpenClaw `package.json`). To bump only the plugin/agent artifacts
(Claude Code `plugin.json` + root/local marketplaces, Codex `plugin.json`,
Hermes `plugin.yaml` + `__init__.py`, OpenClaw `package.json`). To bump only
the plugin/agent artifacts
out of band, use `just set-packages-version <version>` (preview with
`just set-packages-version-dry-run <version>`).
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- MCP Registry is updated manually via `mcp-publisher publish`
- Supports multiple installation methods (uv, pip, Homebrew)
- Supports multiple installation methods (uv, pip, Homebrew)
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/basic-machines-review
+28
View File
@@ -108,6 +108,27 @@ Before opening or updating a PR, run the checks that mirror the common required
- Follow the repository pattern for data access
- Tools communicate to api routers via the httpx ASGI client (in process)
### Programming Style
See [docs/ENGINEERING_STYLE.md](docs/ENGINEERING_STYLE.md) for the fuller house style. The
short version for agents:
- Prefer type-safe, explicit designs over object-heavy indirection. Use Python 3.12 `type`
aliases, full annotations, and narrow `Protocol`s when a caller only needs a capability.
- Use dataclasses for internal value objects and operation results; use Pydantic v2 at API,
CLI, MCP, and persistence boundaries where validation and serialization matter.
- Keep async boundaries obvious. Resource-owning code should use context managers, propagate
cancellation, and avoid hidden background work unless the lifecycle is explicit.
- Fail fast. Do not add silent fallback logic, broad exception swallowing, speculative
`getattr`, or casts that hide an unclear model shape.
- Keep control flow simple and local. Push branching decisions up, keep leaf helpers focused,
and name values after the domain concept they carry.
- Use evidence-first testing. Add or update meaningful regression tests for bugs and risky
behavior, prefer real code paths over mocks, and run the narrowest command that proves the
change before widening verification.
- Comments should explain why a branch, invariant, or constraint exists. Avoid comments that
merely narrate obvious code.
### Code Change Guidelines
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
@@ -348,6 +369,13 @@ See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `c
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
**Cloud Sync Commands (Personal and Team workspaces):**
- Fetch cloud changes (cloud -> local): `basic-memory cloud pull --name "name"` (Team-safe; additive, never deletes local)
- Upload local changes (local -> cloud): `basic-memory cloud push --name "name"` (Team-safe; additive, never deletes cloud)
- Resolve conflicts on push/pull: `--on-conflict [fail|keep-local|keep-cloud|keep-both]` (default `fail` lists conflicts and aborts, git-style)
- One-way mirror (local -> cloud): `basic-memory cloud sync --name "name"` (Personal workspaces only; deletes cloud files missing locally)
- Two-way mirror (local <-> cloud): `basic-memory cloud bisync --name "name"` (Personal workspaces only)
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
+64
View File
@@ -0,0 +1,64 @@
# Basic Memory Engineering Style
Style is how we make code easier to verify. Prefer explicit, typed, local-first code that
preserves the file system as the source of truth while keeping the database, API, and MCP
surfaces in sync.
## Design Center
- Basic Memory is local-first. Markdown files are the durable source; SQLite/Postgres indexes
are derived state that should be rebuilt or reconciled from files when needed.
- Keep the existing boundary order: CLI/MCP/API entrypoints compose dependencies, services own
business behavior, repositories own database access, and file services own filesystem writes.
- MCP tools should remain atomic and composable. They should call API routers through typed MCP
clients, not reach around into services.
- Prefer small, explicit abstractions that match a real domain boundary. Avoid object
hierarchies when a function, dataclass, type alias, or protocol describes the concept better.
## Types And Data
- Use full type annotations and Python 3.12 syntax. Introduce `type` aliases for repeated
structured shapes, callback signatures, or domain concepts that would otherwise become
anonymous `dict[str, Any]` values.
- Use dataclasses for internal values, operation inputs, and service results. Prefer
`frozen=True` when the value should not change and `slots=True` when identity/dynamic
attributes are not needed.
- Use Pydantic v2 at boundaries that validate, serialize, or deserialize data: API payloads,
CLI/MCP schemas, configuration, and persistence-adjacent schemas.
- Use narrow `Protocol`s when a caller needs a capability rather than a concrete repository or
service. Keep protocols small enough that fake implementations in tests are obvious.
- Avoid speculative `getattr`, broad casts, or `Any` as a way to paper over uncertainty. Read
the model or schema definition and make the type relationship explicit.
## Control Flow And Resources
- Fail fast when an invariant is broken. Do not swallow exceptions, add warning-only error
handling, or introduce fallback behavior unless the user explicitly agrees to that behavior.
- Keep control flow simple and close to the domain decision. Push `if` statements up into the
function that owns orchestration; keep leaf helpers focused on computation or one side effect.
- Make async/resource boundaries visible with context managers and explicit lifecycles. Do not
start background work without a clear owner, cancellation story, and verification path.
- Keep file mutations centralized through the existing file utilities/services so checksum,
atomic write, and index synchronization behavior stays coherent.
## Testing And Verification
- Use evidence-first testing, not mechanical TDD. For bugs and risky behavior, add or update a
regression test that would catch the failure. For small documentation-only edits, use the
relevant doc/repo hygiene checks.
- Prefer tests that exercise real code paths. Use mocks, doubles, or `monkeypatch` only when
the external boundary would be slow, nondeterministic, or impossible to trigger directly.
- Keep coverage at 100% for new code. Use `# pragma: no cover` only for code that would require
disproportionate mocking and is covered through an integration or runtime path.
- Start with targeted commands, then widen as risk grows: focused pytest, `just fast-check`,
`just doctor`, package checks for agent packaging changes, and full SQLite/Postgres gates
when behavior crosses shared boundaries.
## Comments And Names
- Name values after the domain concept they carry: project, entity, permalink, tenant, route,
checksum, observation, relation, batch, or index state.
- Comments should say why a branch, invariant, retry, lifecycle, or compatibility constraint
exists. Section headers are useful when a function or file has clear phases.
- Avoid comments that restate the code. If a comment cannot explain a decision, simplify the
code or improve the name instead.
+171 -53
View File
@@ -8,9 +8,25 @@ The cloud CLI enables you to:
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
- **Project-scoped sync** - Each project independently manages its sync configuration
- **Explicit operations** - Sync only what you want, when you want
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
- **Team-safe push/pull** - Additive, git-style transfers that work on shared Team workspaces
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync (Personal workspaces)
- **Offline access** - Work locally, sync when ready
### Personal vs Team workspaces
The transfer commands fall into two groups:
| Command | Direction | Behavior | Personal | Team |
|---|---|---|---|---|
| `bm cloud pull` | cloud → local | **additive** — never deletes local | ✅ | ✅ |
| `bm cloud push` | local → cloud | **additive** — never deletes cloud | ✅ | ✅ |
| `bm cloud sync` | local → cloud | **mirror** — deletes cloud files missing locally | ✅ | ❌ |
| `bm cloud bisync` | local ↔ cloud | **mirror** — two-way, deletes on both sides | ✅ | ❌ |
`sync` and `bisync` are mirror operations: one local tree becomes authoritative and files missing on the other side get deleted. That is correct for a Personal workspace (one user, one source of truth) but unsafe on a shared Team bucket, where it could delete a teammate's files. On Team workspaces these commands exit early with a clear error and point you at `push`/`pull`.
`push` and `pull` are additive (they use `rclone copy`, which never deletes on the destination), so they are safe on both Personal and Team workspaces.
## Prerequisites
Before using Basic Memory Cloud, you need:
@@ -55,8 +71,8 @@ bm project add work --cloud --local-path ~/work-notes
bm project add temp --cloud # No local sync
# Now you can sync individually (after initial --resync):
bm project bisync --name research
bm project bisync --name work
bm cloud bisync --name research
bm cloud bisync --name work
# temp stays cloud-only
```
@@ -137,10 +153,10 @@ Establish the initial sync baseline. **Best practice:** Always preview with `--d
```bash
# Step 1: Preview the initial sync (recommended)
bm project bisync --name research --resync --dry-run
bm cloud bisync --name research --resync --dry-run
# Step 2: If all looks good, run the actual sync
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What happens under the covers:**
@@ -167,7 +183,7 @@ This will effectively make both Path1 and Path2 filesystems contain a matching s
After the first sync, just run bisync without `--resync`:
```bash
bm project bisync --name research
bm cloud bisync --name research
```
**What happens:**
@@ -235,7 +251,7 @@ bm project add research --cloud --local-path ~/Documents/research
- Stores sync config in `~/.basic-memory/config.json`
- Prepares for bisync (but doesn't sync yet)
**Result:** Project ready to sync. Run `bm project bisync --name research --resync` to establish baseline.
**Result:** Project ready to sync. Run `bm cloud bisync --name research --resync` to establish baseline.
**Use case 3: Add sync to existing cloud project**
@@ -294,18 +310,98 @@ For MCP stdio, routing is always local.
### Understanding the Sync Commands
**There are three sync-related commands:**
**There are five sync-related commands:**
1. `bm project sync` - One-way: local → cloud (make cloud match local)
2. `bm project bisync` - Two-way: local ↔ cloud (recommended)
3. `bm project check` - Verify files match (no changes)
| Command | Direction | Workspace | Summary |
|---|---|---|---|
| `bm cloud pull` | cloud → local | Personal + Team | Fetch cloud changes, additively (git-style) |
| `bm cloud push` | local → cloud | Personal + Team | Upload local changes, additively (git-style) |
| `bm cloud sync` | local → cloud | Personal only | One-way mirror (cloud becomes identical to local) |
| `bm cloud bisync` | local ↔ cloud | Personal only | Two-way mirror (recommended for solo use) |
| `bm cloud check` | — | Personal + Team | Verify files match (no changes) |
### One-Way Sync: Local → Cloud
If you collaborate on a shared Team workspace, use **`push`/`pull`** (see [Team Workspaces](#team-workspaces-push--pull-additive-git-style)). If you are the only writer (a Personal workspace), the mirror commands `sync`/`bisync` give you a single source of truth.
### Team Workspaces: push / pull (additive, git-style)
`push` and `pull` are the Team-safe transfer commands. They model `git push` / `git pull`:
- **`bm cloud pull`** fetches changes from the cloud into your local directory.
- **`bm cloud push`** uploads your local changes to the cloud.
Both use `rclone copy`, so they are **additive — they never delete on the destination**. A conflict (a file that differs on both sides) is never resolved silently: by default the command aborts and lists the conflicting files, exactly like git refusing to clobber your changes.
#### Pull: fetch cloud changes
```bash
# Preview first (recommended)
bm cloud pull --name research --dry-run
# Fetch new/changed cloud files into local
bm cloud pull --name research
```
**What happens:**
1. Compares cloud and local with `rclone check`
2. Downloads files that are new or changed on the cloud
3. Leaves your local-only files untouched (never deletes local)
4. If any file differs on both sides, aborts and lists the conflicts (unless you pass `--on-conflict`)
#### Push: upload local changes
```bash
bm cloud push --name research --dry-run
bm cloud push --name research
```
**What happens:**
1. Compares local and cloud with `rclone check`
2. Uploads files that are new or changed locally
3. Leaves cloud-only files untouched (never deletes cloud)
4. If any file differs on both sides, aborts and lists the conflicts — pull first, like a rejected `git push`
#### Resolving conflicts
When `push`/`pull` reports conflicts, re-run with `--on-conflict` to choose how differing files are handled. The value names exactly what survives, so it reads the same in both directions:
| `--on-conflict` | Behavior |
|---|---|
| `fail` *(default)* | List the conflicting files and exit without transferring anything |
| `keep-cloud` | Take the cloud version (pull: overwrite local; push: skip those files) |
| `keep-local` | Keep the local version (pull: skip those files; push: overwrite cloud) |
| `keep-both` | Keep both — write the incoming version beside the existing one as `name.conflict-<date>.md` |
```bash
# A teammate edited notes you also changed locally — pull reports a conflict:
bm cloud pull --name research
# pull aborted: 1 file(s) differ between local and cloud.
# * notes/decisions.md
# Re-run with one of: --on-conflict keep-cloud | keep-local | keep-both
# Take the cloud copy:
bm cloud pull --name research --on-conflict keep-cloud
# Or keep both versions to merge by hand:
bm cloud pull --name research --on-conflict keep-both
```
#### Limitations
`push`/`pull` are deliberately simple, conflict-aware byte transfers — not a full reconciler. Without a sync baseline:
- **Deletions are not propagated.** A note deleted on one side is not removed from the other (we cannot tell an intentional delete from a file the other side never had). This is surfaced in the command output.
- **Every divergence is treated as a conflict.** We cannot tell a teammate's edit from your stale copy, so any differing file prompts a decision rather than auto-resolving.
For conflict-aware *editing*, write through the MCP/API tools (which merge at the note level). A Team-safe bidirectional reconciler with a real baseline is tracked in [issue #862](https://github.com/basicmachines-co/basic-memory/issues/862).
### One-Way Sync: Local → Cloud (Personal only)
**Use case:** You made changes locally and want to push to cloud (overwrite cloud).
> **Personal workspaces only.** `sync` is a destructive mirror — it deletes cloud files that are not present locally. On a Team workspace it would delete a teammate's files, so it is blocked there. Use `bm cloud push` (additive) on Team workspaces.
```bash
bm project sync --name research
bm cloud sync --name research
```
**What happens:**
@@ -321,16 +417,18 @@ bm project sync --name research
- You want to force cloud to match local
- You don't care about cloud changes
### Two-Way Sync: Local ↔ Cloud (Recommended)
### Two-Way Sync: Local ↔ Cloud (Personal only, recommended for solo use)
**Use case:** You edit files both locally and in cloud UI, want both to stay in sync.
> **Personal workspaces only.** `bisync` is a two-way mirror that can delete and overwrite on both sides. It is blocked on Team workspaces — use `bm cloud pull` then `bm cloud push` there. A Team-safe bidirectional reconciler is tracked separately ([issue #862](https://github.com/basicmachines-co/basic-memory/issues/862)).
```bash
# First time - establish baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
# Subsequent syncs
bm project bisync --name research
bm cloud bisync --name research
```
**What happens:**
@@ -349,7 +447,7 @@ echo "Local change" > ~/Documents/research/notes.md
# Cloud now has: "Cloud change"
# Run bisync
bm project bisync --name research
bm cloud bisync --name research
# Result: Newer file wins (based on modification time)
# If cloud was more recent, cloud version kept
@@ -366,7 +464,7 @@ bm project bisync --name research
**Use case:** Check if local and cloud match without making changes.
```bash
bm project check --name research
bm cloud check --name research
```
**What happens:**
@@ -378,7 +476,7 @@ bm project check --name research
```bash
# One-way check (faster)
bm project check --name research --one-way
bm cloud check --name research --one-way
```
### Preview Changes (Dry Run)
@@ -386,7 +484,7 @@ bm project check --name research --one-way
**Use case:** See what would change without actually syncing.
```bash
bm project bisync --name research --dry-run
bm cloud bisync --name research --dry-run
```
**What happens:**
@@ -432,20 +530,20 @@ bm project add work --cloud --local-path ~/work-notes
bm project add personal --cloud --local-path ~/personal
# Establish baselines
bm project bisync --name research --resync
bm project bisync --name work --resync
bm project bisync --name personal --resync
bm cloud bisync --name research --resync
bm cloud bisync --name work --resync
bm cloud bisync --name personal --resync
# Daily workflow: sync everything
bm project bisync --name research
bm project bisync --name work
bm project bisync --name personal
bm cloud bisync --name research
bm cloud bisync --name work
bm cloud bisync --name personal
```
**Future:** `--all` flag will sync all configured projects:
```bash
bm project bisync --all # Coming soon
bm cloud bisync --all # Coming soon
```
### Mixed Usage
@@ -462,8 +560,8 @@ bm project add archive --cloud
bm project add temp-notes --cloud
# Sync only the configured ones
bm project bisync --name research
bm project bisync --name work
bm cloud bisync --name research
bm cloud bisync --name work
# Archive and temp-notes stay cloud-only
```
@@ -661,7 +759,7 @@ code ~/.basic-memory/.bmignore
echo "*.tmp" >> ~/.basic-memory/.bmignore
# Next sync uses updated patterns
bm project bisync --name research
bm cloud bisync --name research
```
## Troubleshooting
@@ -724,7 +822,7 @@ bm cloud login
**Solution:**
```bash
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What this does:**
@@ -747,7 +845,7 @@ bm project bisync --name research --resync
echo "# Research Notes" > ~/Documents/research/README.md
# Now run bisync
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**Why this happens:** Bisync creates listing files that track the state of each side. When both directories are completely empty, these listing files are considered invalid by rclone.
@@ -764,10 +862,10 @@ bm project bisync --name research --resync
```bash
# Clear bisync state
bm project bisync-reset research
bm cloud bisync-reset research
# Re-establish baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What this does:**
@@ -787,16 +885,16 @@ bm project bisync --name research --resync
```bash
# Check what would be deleted
bm project bisync --name research --dry-run
bm cloud bisync --name research --dry-run
# If correct, establish new baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**Solution 2:** Use one-way sync if you know local is correct:
```bash
bm project sync --name research
bm cloud sync --name research
```
### Project Not Configured for Sync
@@ -809,7 +907,7 @@ bm project sync --name research
```bash
bm cloud sync-setup research ~/Documents/research
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
### Connection Issues
@@ -880,20 +978,30 @@ bm project set-local <name> # Revert project to local mode
### File Synchronization
```bash
# One-way sync (local → cloud)
bm project sync --name <project>
bm project sync --name <project> --dry-run
bm project sync --name <project> --verbose
# Pull: fetch cloud changes (cloud → local) - Personal + Team, additive
bm cloud pull --name <project>
bm cloud pull --name <project> --dry-run
bm cloud pull --name <project> --on-conflict [fail|keep-local|keep-cloud|keep-both]
# Two-way sync (local cloud) - Recommended
bm project bisync --name <project> # After first --resync
bm project bisync --name <project> --resync # First time / force baseline
bm project bisync --name <project> --dry-run
bm project bisync --name <project> --verbose
# Push: upload local changes (local cloud) - Personal + Team, additive
bm cloud push --name <project>
bm cloud push --name <project> --dry-run
bm cloud push --name <project> --on-conflict [fail|keep-local|keep-cloud|keep-both]
# One-way mirror (local → cloud) - Personal workspaces only
bm cloud sync --name <project>
bm cloud sync --name <project> --dry-run
bm cloud sync --name <project> --verbose
# Two-way mirror (local ↔ cloud) - Personal workspaces only
bm cloud bisync --name <project> # After first --resync
bm cloud bisync --name <project> --resync # First time / force baseline
bm cloud bisync --name <project> --dry-run
bm cloud bisync --name <project> --verbose
# Integrity check
bm project check --name <project>
bm project check --name <project> --one-way
bm cloud check --name <project>
bm cloud check --name <project> --one-way
# List project files by route
bm project ls --name <project> # Default target: local
@@ -909,15 +1017,25 @@ bm project ls --name <project> --cloud --path <subpath>
1. **Authenticate cloud access** - `bm cloud login`
2. **Install rclone** - `bm cloud setup`
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
5. **Establish baseline** - `bm project bisync --name research --resync`
6. **Daily workflow** - `bm project bisync --name research`
**Personal workspace (solo, mirror) workflow:**
4. **Preview first sync** - `bm cloud bisync --name research --resync --dry-run`
5. **Establish baseline** - `bm cloud bisync --name research --resync`
6. **Daily workflow** - `bm cloud bisync --name research`
**Team workspace (shared, additive) workflow:**
4. **Fetch teammates' changes** - `bm cloud pull --name research`
5. **Upload your changes** - `bm cloud push --name research`
6. **Resolve conflicts explicitly** - re-run with `--on-conflict keep-cloud|keep-local|keep-both`
**Key benefits:**
- ✅ Each project independently syncs (or doesn't)
- ✅ Projects can live anywhere on disk
- ✅ Explicit sync operations (no magic)
-Safe by design (max delete limits, conflict resolution)
-Team-safe push/pull that never delete on the destination
- ✅ Safe by design (max delete limits, conflict resolution, git-style conflict aborts)
- ✅ Full offline access (work locally, sync when ready)
**Future enhancements:**
+300
View File
@@ -0,0 +1,300 @@
# LiteLLM Provider
Basic Memory can use the LiteLLM SDK for semantic search embeddings. This lets you
keep Basic Memory's vector indexing and search behavior while routing embedding calls
to OpenAI-compatible and provider-specific backends such as OpenAI, Azure OpenAI,
Cohere, Bedrock, NVIDIA NIM, and other LiteLLM-supported embedding providers.
Use this page when you want to try a non-default embedding model, validate a provider,
or tune LiteLLM-specific settings.
> **Experimental — advanced users only.** The LiteLLM provider is experimental and
> intended for users who are comfortable operating remote embedding backends. It makes
> paid, networked API calls, requires per-model dimension and input-role configuration,
> and reindexing a real corpus can be slow and spend provider quota (see
> [Reindexing with a remote provider](#reindexing-with-a-remote-provider)). For most
> users, the default local **FastEmbed** provider is the recommended choice. Use LiteLLM
> only if you know what you're doing.
## Quick Start
The default LiteLLM model is OpenAI `text-embedding-3-small` through the LiteLLM
model string `openai/text-embedding-3-small`.
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export OPENAI_API_KEY=sk-...
bm reindex --embeddings
```
Then use vector or hybrid search:
```python
search_notes("login token flow", search_type="hybrid")
```
## Basic Memory Options
All options can be set in config or as environment variables.
| Config Field | Env Var | Default | Notes |
|---|---|---|---|
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto | Set to `true` to force vector/hybrid support on. |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `fastembed` | Set to `litellm` for the LiteLLM provider. |
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `bge-small-en-v1.5` | With `litellm`, the default is remapped to `openai/text-embedding-3-small`. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Provider default | Required for non-default LiteLLM models because vector tables are dimensioned before the first API call. |
| `semantic_embedding_forward_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS` | Auto | Sends `dimensions` to LiteLLM only when supported. Auto is enabled for `text-embedding-3` model strings. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto | LiteLLM `input_type` for indexed notes/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto | LiteLLM `input_type` for search queries. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of text chunks per provider request. |
| `semantic_embedding_request_concurrency` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_REQUEST_CONCURRENCY` | `4` | Maximum concurrent LiteLLM embedding requests. |
| `semantic_embedding_sync_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE` | `2` | Number of prepared vector jobs flushed through the sync pipeline together. |
## Dimensions
Basic Memory needs the vector dimension before it can create SQLite or Postgres
vector tables. The OpenAI default is known, so this works without an explicit
dimension:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=openai/text-embedding-3-small
```
For every other LiteLLM model, set the dimension explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
For fixed-size models, `semantic_embedding_dimensions` is Basic Memory's local
schema and validation size. For OpenAI/Azure `text-embedding-3` models, LiteLLM
can also forward `dimensions` as a provider-side reduced-output request. Basic
Memory enables that automatically when the model string contains `text-embedding-3`.
If you use an Azure deployment alias such as `azure/<deployment-name>`, the model
string may not reveal that the underlying model supports reduced output dimensions.
Set this only when your deployment supports it:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true
```
## Asymmetric Models
Some embedding models use different request roles for indexed documents and
search queries. Basic Memory automatically sets these for known LiteLLM families:
| Model Family | Document `input_type` | Query `input_type` |
|---|---|---|
| Cohere v3 embeddings | `search_document` | `search_query` |
| NVIDIA NIM retrieval embeddings | `passage` | `query` |
For any other asymmetric model, configure both roles explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```
Changing provider, model, dimensions, dimension-forwarding, or document/query
roles changes the meaning of stored vectors. Rebuild embeddings after any of
those changes:
```bash
bm reindex --embeddings
```
## Reindexing with a remote provider
Embedding a real corpus through a network API is far slower than local FastEmbed, and
the defaults are tuned for the local case. Two things to know before you run a full
reindex.
**Raise the sync batch size.** `semantic_embedding_sync_batch_size` defaults to `2`, and
it — not `semantic_embedding_batch_size` — governs throughput on the sync pipeline. With
the default, a full reindex can take tens of seconds *per note* against a remote provider.
Raising both to a larger value turns a multi-minute (or longer) reindex into well under a
minute for the same corpus:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE=32
export BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE=64
```
Stay within the provider's per-request size and rate limits — Cohere v3, for example,
accepts up to 96 inputs per embedding request.
**Changing dimensions requires recreating the vector table.** Basic Memory dimensions the
vector table on first index and refuses to mix sizes. Switching to a model with a
different dimension (for example FastEmbed 384 → OpenAI 1536 → Cohere 1024) makes a plain
`bm reindex` raise an `Embedding dimension mismatch` error. Recreate the table with a full
rebuild — files are the source of truth, so this re-indexes from disk and re-embeds
everything:
```bash
bm reset --reindex
```
To trial a provider without disturbing your existing index, point Basic Memory at a
throwaway config + database instead:
```bash
export BASIC_MEMORY_CONFIG_DIR=/tmp/bm-litellm-trial
```
## Provider Setup Examples
LiteLLM reads provider credentials from the environment. These are the examples
covered by Basic Memory's live validation harness.
### OpenAI Through LiteLLM
```bash
export OPENAI_API_KEY=sk-...
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=openai/text-embedding-3-small
```
### Cohere v3
```bash
export COHERE_API_KEY=...
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
The provider auto-selects `search_document` for indexed chunks and `search_query`
for search queries.
### Azure OpenAI
```bash
export AZURE_API_KEY=...
export AZURE_API_BASE=https://<resource-name>.openai.azure.com
export AZURE_API_VERSION=2024-02-01
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=azure/<deployment-name>
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1536
```
If your Azure deployment is a reduced-dimension `text-embedding-3` deployment,
set the dimension you want and enable forwarding:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=512
export BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true
```
### NVIDIA NIM
```bash
export NVIDIA_NIM_API_KEY=...
# Optional when using a custom or self-hosted NIM endpoint:
export NVIDIA_NIM_API_BASE=https://integrate.api.nvidia.com/v1
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=nvidia_nim/nvidia/embed-qa-4
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
The provider auto-selects `passage` for indexed chunks and `query` for search
queries.
## Testing LiteLLM Providers
Run the non-live LiteLLM unit and harness tests first:
```bash
uv run pytest tests/repository/test_litellm_provider.py \
test-int/semantic/test_litellm_live_harness.py -q
```
Run the SQLite and Postgres vector identity regressions when changing model
identity, role, or vector sync behavior:
```bash
uv run pytest \
tests/repository/test_sqlite_vector_search_repository.py::test_sqlite_embedding_model_key_includes_litellm_role_settings \
-q
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest \
tests/repository/test_postgres_search_repository.py::test_postgres_litellm_role_change_reembeds_existing_chunks \
-q
```
The Postgres command uses testcontainers, so Docker must be running.
## Live Provider Harness
The live harness makes real LiteLLM API calls and spends provider quota. It is
opt-in by design:
```bash
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
just test-litellm-live
```
Built-in cases run when their API keys are present:
| Case | Required Env Var | Validates |
|---|---|---|
| `openai-text-embedding-3-small` | `OPENAI_API_KEY` | OpenAI via LiteLLM, 1536 dimensions, normalized vectors, ranking sanity. |
| `cohere-embed-english-v3` | `COHERE_API_KEY` | Cohere v3 role handling, 1024 dimensions, normalized vectors, ranking sanity. |
Add provider aliases or new backends with a custom cases file:
```bash
cat > /tmp/litellm-cases.json <<'JSON'
[
{
"name": "azure-text-embedding-3-small-512",
"model": "azure/<deployment-name>",
"dimensions": 512,
"api_key_env": "AZURE_API_KEY",
"forward_dimensions": true
},
{
"name": "nvidia-embed-qa-4",
"model": "nvidia_nim/nvidia/embed-qa-4",
"dimensions": 1024,
"api_key_env": "NVIDIA_NIM_API_KEY",
"document_input_type": "passage",
"query_input_type": "query"
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-cases.json
```
For CI-style output:
```bash
just test-litellm-live --cases-file /tmp/litellm-cases.json --json
```
The harness embeds two documents and one query, validates dimension and vector
normalization, checks that the authentication query ranks the authentication
document above a distractor, and reports latency plus role/dimension settings.
## Provider Reference
LiteLLM's own provider and embedding docs are the source of truth for current
model strings and credential names:
- [LiteLLM embedding models](https://docs.litellm.ai/docs/embedding/supported_embedding)
- [LiteLLM Azure OpenAI provider](https://docs.litellm.ai/docs/providers/azure)
- [LiteLLM NVIDIA NIM provider](https://docs.litellm.ai/docs/providers/nvidia_nim)
+116 -5
View File
@@ -99,10 +99,13 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va
| Config Field | Env Var | Default | Description |
|---|---|---|---|
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto (`true` when semantic deps are available) | Enable semantic search. Required before vector/hybrid modes work. |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local), `"openai"` (API), or `"litellm"` (multi-provider API, **experimental** — advanced users only). |
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Provider default | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI/LiteLLM OpenAI. Required when using a non-default LiteLLM model. |
| `semantic_embedding_forward_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS` | Auto | LiteLLM-only override for whether configured dimensions are sent as a provider-side output-size request. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of texts to embed per batch. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for indexed document/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for search queries. |
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
## Embedding Providers
@@ -135,7 +138,114 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
export OPENAI_API_KEY=sk-...
```
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
### LiteLLM
> **Experimental — advanced users only.** The LiteLLM provider is experimental and aimed at users comfortable operating remote embedding backends: paid API calls, per-model dimension and input-role configuration, and slower reindexing of large corpora. For most users, FastEmbed (local, default) is recommended. See [LiteLLM Provider](litellm-provider.md) for the caveats and tuning.
Uses the LiteLLM SDK to call embedding models from providers such as OpenAI, Cohere, Azure, Bedrock, NVIDIA NIM, and other LiteLLM-supported backends. Requires the provider's API credentials.
For the full option reference, provider setup examples, and live validation harness, see [LiteLLM Provider](litellm-provider.md).
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
export COHERE_API_KEY=...
```
Basic Memory creates vector tables before the first embedding call, so non-default LiteLLM models must set `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS`. The LiteLLM OpenAI default (`openai/text-embedding-3-small`) uses 1536 dimensions automatically.
For fixed-size LiteLLM models, dimensions are used as Basic Memory's local vector schema and
validation size. Basic Memory automatically sends dimensions as a provider-side output-size
request for `text-embedding-3` model strings, where LiteLLM/OpenAI support reduced output
dimensions. If an Azure/OpenAI deployment uses an arbitrary LiteLLM model string such as
`azure/<deployment-name>` and the underlying model supports reduced dimensions, set
`BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true`.
Some retrieval models are asymmetric: indexed passages and search queries must be embedded with different provider parameters. Basic Memory automatically sets LiteLLM `input_type` for known asymmetric model families:
- Cohere v3: documents use `search_document`, queries use `search_query`
- NVIDIA NIM retrieval models: documents use `passage`, queries use `query`
For other asymmetric LiteLLM models, set the input types explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```
#### Live LiteLLM Validation
Provider APIs differ in subtle ways: some accept `dimensions`, some require separate
document/query roles, and some route through deployment aliases that do not reveal the
underlying model name. Before adding or changing LiteLLM model support, run the opt-in live
evaluation harness:
```bash
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
just test-litellm-live
```
The built-in live cases cover:
| Case | Required key | What it validates |
|---|---|---|
| `openai/text-embedding-3-small` | `OPENAI_API_KEY` | Standard LiteLLM OpenAI embedding calls and normalized 1536-dimensional output. |
| `cohere/embed-english-v3.0` | `COHERE_API_KEY` | Cohere v3 asymmetric `search_document` / `search_query` handling and fixed 1024-dimensional output. |
The harness embeds two documents and one query, checks vector dimensions and normalization,
then verifies the authentication query ranks the authentication document above the distractor.
It prints a table with per-model scores, norms, latency, role settings, and dimension-forwarding
mode.
To validate provider aliases or additional LiteLLM backends, save custom JSON cases:
```bash
export AZURE_API_KEY=...
export AZURE_API_BASE=https://example.openai.azure.com
export AZURE_API_VERSION=2024-02-01
cat > /tmp/litellm-azure-cases.json <<'JSON'
[
{
"name": "azure-text-embedding-3-small-512",
"model": "azure/<deployment-name>",
"dimensions": 512,
"api_key_env": "AZURE_API_KEY",
"forward_dimensions": true
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-azure-cases.json
```
NVIDIA NIM retrieval models can be checked the same way:
```bash
export NVIDIA_NIM_API_KEY=...
cat > /tmp/litellm-nvidia-cases.json <<'JSON'
[
{
"name": "nvidia-embed-qa-4",
"model": "nvidia_nim/nvidia/embed-qa-4",
"dimensions": 1024,
"api_key_env": "NVIDIA_NIM_API_KEY",
"document_input_type": "passage",
"query_input_type": "query"
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-nvidia-cases.json
```
For repeatable local runs, put the same JSON array in a file and pass
`just test-litellm-live --cases-file path/to/litellm-cases.json`.
When switching providers, models, dimensions, or LiteLLM document/query input types, rebuild embeddings:
```bash
bm reindex --embeddings
@@ -203,9 +313,10 @@ bm reindex -p my-project
- **Upgrade note**: Migration now performs a one-time automatic embedding backfill on upgrade.
- **Manual enable case**: If you explicitly had `semantic_search_enabled=false` and then turn it on
- **Provider change**: After switching between `fastembed` and `openai`
- **Provider change**: After switching between `fastembed`, `openai`, and `litellm`
- **Model change**: After changing `semantic_embedding_model`
- **Dimension change**: After changing `semantic_embedding_dimensions`
- **LiteLLM role change**: After changing `semantic_embedding_document_input_type` or `semantic_embedding_query_input_type`
The reindex command shows progress with embedded/skipped/error counts:
+7 -1
View File
@@ -115,6 +115,10 @@ test-semantic-report:
BASIC_MEMORY_ENV=test BASIC_MEMORY_BENCHMARK_OUTPUT=.benchmarks/semantic-quality.jsonl uv run pytest -p pytest_mock -v -s --no-cov -m semantic test-int/semantic/
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl
# Run opt-in live LiteLLM provider checks against configured external APIs
test-litellm-live *args:
BASIC_MEMORY_ENV=test BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 PYTHONPATH=test-int:src uv run python -m semantic.litellm_live_harness {{args}}
# Run semantic benchmarks (Postgres combos only)
test-semantic-postgres:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic -k postgres test-int/semantic/
@@ -307,7 +311,7 @@ set-version version scope="all":
set-version-dry-run version scope="all":
python3 scripts/update_versions.py "{{version}}" --scope "{{scope}}" --dry-run
# Set the version for just the plugin/agent artifacts (plugin, marketplaces, Hermes, OpenClaw)
# Set the version for just the plugin/agent artifacts (plugins, marketplaces, Hermes, OpenClaw)
set-packages-version version:
just set-version "{{version}}" packages
@@ -369,6 +373,7 @@ release version:
.claude-plugin/marketplace.json \
plugins/claude-code/.claude-plugin/plugin.json \
plugins/claude-code/.claude-plugin/marketplace.json \
plugins/codex/.codex-plugin/plugin.json \
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
@@ -442,6 +447,7 @@ beta version:
.claude-plugin/marketplace.json \
plugins/claude-code/.claude-plugin/plugin.json \
plugins/claude-code/.claude-plugin/marketplace.json \
plugins/codex/.codex-plugin/plugin.json \
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "0.1.0+codex.20260604201213",
"version": "0.21.6",
"description": "A Codex-native bridge to Basic Memory for durable engineering context, decisions, and resumable checkpoints.",
"author": {
"name": "Basic Machines",
+6
View File
@@ -48,8 +48,13 @@ dependencies = [
"fastembed>=0.7.4",
"sqlite-vec>=0.1.6",
"openai>=1.100.2",
"litellm>=1.60.0,<2.0.0",
"logfire>=4.19.0",
"psutil>=5.9.0",
# uvloop's C event loop has no self._ready.popleft() codepath, so the
# asyncpg engine-dispose race ("IndexError: pop from an empty deque") that
# crashes the Postgres backend cannot fire under it. Not available on Windows.
"uvloop>=0.21.0; sys_platform != 'win32'",
]
[project.urls]
@@ -84,6 +89,7 @@ markers = [
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
"smoke: Fast end-to-end smoke tests for MCP flows",
"semantic: Tests requiring semantic dependencies (fastembed, sqlite-vec, openai)",
"live: Tests that call external provider APIs and require explicit opt-in",
]
[tool.ruff]
+7 -2
View File
@@ -99,7 +99,7 @@ def set_package_version(data: dict[str, Any], version: str) -> None:
# Version scopes. The two groups map to the two distribution tracks:
# core — the Python package and its MCP registry manifest
# packages — the host-native agent artifacts (Claude Code plugin + marketplaces,
# Hermes, OpenClaw). These are the "plugin/agent artifacts."
# Codex plugin, Hermes, OpenClaw). These are the "plugin/agent artifacts."
# `all` writes both. Lockstep releases use `all`; targeted fixes can use one group.
SCOPES = ("all", "core", "packages")
@@ -134,6 +134,11 @@ def _update_packages(version: str, *, dry_run: bool) -> None:
lambda data: set_claude_marketplace_version(data, version),
dry_run=dry_run,
)
update_json(
"plugins/codex/.codex-plugin/plugin.json",
lambda data: set_package_version(data, npm_package_version(version)),
dry_run=dry_run,
)
update_text(
"integrations/hermes/plugin.yaml",
r"^version:\s*.*$",
@@ -174,7 +179,7 @@ def main() -> None:
choices=SCOPES,
default="all",
help="Which artifacts to update: all (default), core (Python + server.json), "
"or packages (Claude Code plugin, marketplaces, Hermes, OpenClaw)",
"or packages (Claude Code plugin, Codex plugin, marketplaces, Hermes, OpenClaw)",
)
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing")
args = parser.parse_args()
+29 -3
View File
@@ -4,9 +4,31 @@
from __future__ import annotations
import argparse
import re
from pathlib import Path
PLAIN_SCALAR_MAPPING_VALUE = re.compile(r":(?:\s|$)")
def strip_matching_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value
def validate_plain_scalar(path: Path, line_number: int, key: str, value: str) -> None:
"""Catch invalid plain-scalar YAML that Codex rejects while loading skills."""
stripped = value.strip()
if not stripped or stripped[0] in {'"', "'"} or stripped in {"|", ">", "|-", ">-", "|+", ">+"}:
return
if PLAIN_SCALAR_MAPPING_VALUE.search(stripped):
raise SystemExit(
f"{path}:{line_number}: invalid YAML frontmatter for {key!r}: "
"unquoted ':' followed by whitespace; quote the value"
)
def parse_frontmatter(path: Path) -> dict[str, str]:
"""Extract top-level frontmatter keys from a Markdown file.
@@ -15,14 +37,15 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
skipped, so nested blocks (a schema note's `schema:`/`settings:` children) can't
overwrite a top-level key like `type` or `entity` via last-write-wins. It does
not interpret block scalars or multi-line values; callers rely on single-line
top-level fields (name, description, type, entity).
top-level fields (name, description, type, entity). Keep the Codex-facing YAML
guard here dependency-free so package checks work under bare `python3`.
"""
lines = path.read_text().splitlines()
if not lines or lines[0] != "---":
raise SystemExit(f"{path}: missing YAML frontmatter")
frontmatter: dict[str, str] = {}
for line in lines[1:]:
for line_number, line in enumerate(lines[1:], start=2):
if line == "---":
break
if line[:1] in (" ", "\t"): # nested key — not a top-level field
@@ -30,7 +53,10 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
if ":" not in line:
continue
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip().strip('"')
key = key.strip()
value = value.strip()
validate_plain_scalar(path, line_number, key, value)
frontmatter[key] = strip_matching_quotes(value)
else:
raise SystemExit(f"{path}: unclosed YAML frontmatter")
+21 -4
View File
@@ -2,8 +2,11 @@
import asyncio
import os
from contextlib import suppress
from logging.config import fileConfig
from loguru import logger
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
# Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
import sys
@@ -13,9 +16,15 @@ if sys.version_info < (3, 14):
import nest_asyncio
nest_asyncio.apply()
except (ImportError, ValueError):
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
pass
except (ImportError, ValueError) as exc:
# Trigger: nest_asyncio is absent (ImportError) or refuses to patch the
# running loop (ValueError, e.g. the uvloop policy installed for the
# Postgres backend - #831/#877).
# Why: the uvloop ValueError is now an *expected* path on every Postgres
# startup, so swallowing it silently hides a routine branch.
# Outcome: log at DEBUG (observable, not noisy) and fall through to the
# thread-based migration fallback.
logger.debug(f"nest_asyncio not applied ({exc!r}); using thread-based migration fallback")
# For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
from sqlalchemy import engine_from_config, pool
@@ -115,7 +124,15 @@ async def run_async_migrations(connectable):
"""Run migrations asynchronously with AsyncEngine."""
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
# Trigger: startup migrations on the asyncpg backend dispose the engine
# while the event loop may be tearing down around them.
# Why: that race surfaces "IndexError: pop from an empty deque" from
# base_events._run_once (#831/#877); shielding lets dispose finish atomically
# and suppressing CancelledError keeps a cancelled teardown from re-raising it.
# Outcome: the migration engine always disposes cleanly. (uvloop is the
# structural fix for the race; this hardens the teardown path.)
with suppress(asyncio.CancelledError):
await asyncio.shield(connectable.dispose())
def _run_async_migrations_with_asyncio_run(connectable) -> None:
@@ -64,7 +64,9 @@ async def search(
or query.permalink
or query.permalink_match
),
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
has_filters=bool(
query.note_types or query.entity_types or query.categories or query.metadata_filters
),
):
offset = (page - 1) * page_size
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
+4 -2
View File
@@ -18,7 +18,9 @@ from basic_memory.services.context_service import (
class EntityBatchLookup(Protocol):
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Any]: ...
async def find_by_ids_for_hydration(
self, ids: List[int], *, include_cross_project: bool = False
) -> Sequence[Any]: ...
class EntityServiceBatchLookup(Protocol):
@@ -88,7 +90,7 @@ async def to_graph_context(
result_count=len(entity_ids_needed),
):
entities = await entity_repository.find_by_ids_for_hydration(
list(entity_ids_needed)
list(entity_ids_needed), include_cross_project=True
)
for e in entities:
entity_title_lookup[e.id] = e.title
+9
View File
@@ -57,6 +57,14 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Trigger: Postgres backend resolved at CLI startup, before any asyncio.run().
# Why: uvloop must own the event-loop policy before the loop is created so the
# asyncpg engine-dispose race (#831/#877) cannot fire. No-op for SQLite.
# Outcome: subsequent asyncio.run() calls in CLI commands use uvloop on Postgres.
from basic_memory.db import maybe_install_uvloop
maybe_install_uvloop(container.config)
# Trigger: first-run init confirmation before command output.
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
# Outcome: one-time plain line printed before the subcommand runs.
@@ -86,6 +94,7 @@ def app_callback(
"reindex",
"update",
"watch",
"workspace",
}
if (
not version
@@ -9,6 +9,7 @@ from . import (
format,
schema,
update,
workspace,
)
__all__ = [
@@ -27,4 +28,5 @@ __all__ = [
"format",
"schema",
"update",
"workspace",
]
@@ -7,6 +7,7 @@ they are cloud-specific operations.
import os
from datetime import datetime
from enum import Enum
import typer
from rich.console import Console
@@ -16,10 +17,14 @@ from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
from basic_memory.cli.commands.cloud.rclone_commands import (
RcloneError,
SyncProject,
TransferDirection,
TransferPlan,
get_project_bisync_state,
project_bisync,
project_check,
project_diff,
project_sync,
project_transfer,
)
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing
@@ -35,9 +40,34 @@ console = Console()
TEAM_WORKSPACE_BISYNC_UNSUPPORTED = (
"The bisync operation is only supported on Personal workspaces.\n"
"Use `bm cloud sync --name {name}` instead."
"Use `bm cloud pull --name {name}` / `bm cloud push --name {name}` instead."
)
TEAM_WORKSPACE_SYNC_UNSUPPORTED = (
"The sync operation mirrors local onto the shared bucket and can delete a "
"teammate's files, so it is only supported on Personal workspaces.\n"
"Use `bm cloud pull --name {name}` (fetch) / `bm cloud push --name {name}` "
"(additive upload) instead."
)
class ConflictStrategy(str, Enum):
"""How push/pull resolves files that differ on both sides.
Default is ``fail``: surface the conflicts and abort before transferring,
leaving the user to re-run with an explicit resolution — like git refusing
to clobber local changes.
This is the Typer-facing enum; the engine in ``rclone_commands`` accepts the
same values as a ``ConflictStrategy`` Literal. ``_run_directional_transfer``
bridges the two by passing ``on_conflict.value``. Keep the values in sync.
"""
fail = "fail"
keep_local = "keep-local"
keep_cloud = "keep-cloud"
keep_both = "keep-both"
# --- Shared helpers ---
@@ -92,8 +122,18 @@ async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> Wo
)
def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
"""Exit before bisync work when the target workspace is not personal."""
def _require_personal_workspace(
name: str,
config: BasicMemoryConfig,
*,
unsupported_message: str = TEAM_WORKSPACE_BISYNC_UNSUPPORTED,
) -> WorkspaceInfo:
"""Exit before mirror work when the target workspace is not personal.
Used to gate the destructive mirror operations (`sync`, `bisync`) to
Personal workspaces. ``unsupported_message`` lets each command point Team
users at the right Team-safe alternative.
"""
try:
workspace = run_with_cleanup(_get_workspace_for_project(name, config))
except Exception as exc:
@@ -101,7 +141,7 @@ def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> Workspa
raise typer.Exit(1)
if workspace.workspace_type != "personal":
console.print(f"[red]{TEAM_WORKSPACE_BISYNC_UNSUPPORTED.format(name=name)}[/red]")
console.print(f"[red]{unsupported_message.format(name=name)}[/red]")
raise typer.Exit(1)
return workspace
@@ -146,11 +186,15 @@ def _get_sync_project(
@cloud_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
name: str = typer.Option(..., "--name", "--project", help="Project name to sync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""One-way sync: local -> cloud (make cloud identical to local).
"""One-way mirror: local -> cloud (make cloud identical to local).
Personal workspaces only. This deletes cloud files not present locally, so
on Team workspaces use `bm cloud push` (additive upload) / `bm cloud pull`
(fetch) instead.
Example:
bm cloud sync --name research
@@ -158,6 +202,7 @@ def sync_project_command(
"""
config = ConfigManager().config
_require_cloud_credentials(config)
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
try:
# Get tenant info for bucket name
@@ -191,9 +236,175 @@ def sync_project_command(
raise typer.Exit(1)
def _print_conflict_abort(name: str, direction: TransferDirection, plan: TransferPlan) -> None:
"""Explain a conflict abort and how to resolve it (git-pull style)."""
console.print(
f"[red]{direction.capitalize()} aborted: {len(plan.conflicts)} file(s) differ between "
f"local and cloud.[/red]"
)
for path in plan.conflicts:
console.print(f" [yellow]*[/yellow] {path}")
console.print("\nRe-run with one of:")
console.print(" [dim]--on-conflict keep-cloud[/dim] take the cloud version")
console.print(" [dim]--on-conflict keep-local[/dim] keep your local version")
console.print(
" [dim]--on-conflict keep-both[/dim] keep both (writes <name>.conflict-<date>)"
)
def _run_directional_transfer(
name: str,
direction: TransferDirection,
*,
on_conflict: ConflictStrategy,
dry_run: bool,
verbose: bool,
) -> None:
"""Shared orchestration for `bm cloud push` / `bm cloud pull`.
Detects conflicts first, then aborts (the default) or applies the chosen
resolution. Uses additive `rclone copy`, so it never deletes on the
destination — safe for Team workspaces and therefore not gated.
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
with force_routing(cloud=True):
project_data = run_with_cleanup(_get_cloud_project(name))
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
sync_project, _ = _get_sync_project(name, config, project_data)
# --- Detect before transferring ---
plan = project_diff(sync_project, bucket_name, direction)
# Trigger: rclone could not read/hash some files.
# Why: comparing is the whole basis for a safe transfer — never guess.
# Outcome: abort before moving any bytes.
if plan.errors:
console.print(
f"[red]{direction.capitalize()} aborted: rclone could not compare "
f"{len(plan.errors)} file(s)[/red]"
)
for path in plan.errors:
console.print(f" [red]![/red] {path}")
raise typer.Exit(1)
# Trigger: files differ on both sides and the user chose no resolution.
# Why: "no surprises" — never silently pick a winner.
# Outcome: list the conflicts and exit, like git refusing to clobber.
if plan.conflicts and on_conflict is ConflictStrategy.fail:
_print_conflict_abort(name, direction, plan)
raise typer.Exit(1)
# --- Transfer ---
arrow = "cloud -> local" if direction == "pull" else "local -> cloud"
console.print(f"[blue]{direction.capitalize()} {name} ({arrow})...[/blue]")
conflict_suffix = datetime.now().strftime("%Y%m%d-%H%M%S")
success = project_transfer(
sync_project,
bucket_name,
direction,
plan,
strategy=on_conflict.value,
conflict_suffix=conflict_suffix,
dry_run=dry_run,
verbose=verbose,
)
if not success:
console.print(f"[red]{name} {direction} failed[/red]")
raise typer.Exit(1)
console.print(f"[green]{name} {direction} completed successfully[/green]")
# Without a sync baseline (see #862) we cannot tell an intentional delete
# from a file the other side simply never had, so deletions never sync.
if plan.dest_only:
kept_on = "local" if direction == "pull" else "cloud"
console.print(
f"[dim]{len(plan.dest_only)} file(s) exist only on {kept_on} and were left "
"untouched (deletions are not propagated).[/dim]"
)
except RcloneError as e:
console.print(f"[red]{direction.capitalize()} error: {e}[/red]")
raise typer.Exit(1)
except typer.Exit:
# Already-handled exits (not found, conflicts, errors) propagate cleanly.
raise
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("pull")
def pull_project_command(
name: str = typer.Option(..., "--name", "--project", help="Project name to pull"),
on_conflict: ConflictStrategy = typer.Option(
ConflictStrategy.fail,
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pulling"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Fetch cloud changes into local (cloud -> local), git-pull style.
Additive and Team-safe: downloads new/changed cloud files and never deletes
local files. A file that differs on both sides is a conflict; by default
pull aborts and lists them. Deletions are not propagated (see #862).
Examples:
bm cloud pull --name research
bm cloud pull --name research --dry-run
bm cloud pull --name research --on-conflict keep-cloud
"""
_run_directional_transfer(
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@cloud_app.command("push")
def push_project_command(
name: str = typer.Option(..., "--name", "--project", help="Project name to push"),
on_conflict: ConflictStrategy = typer.Option(
ConflictStrategy.fail,
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pushing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Upload local changes to cloud (local -> cloud), additive and Team-safe.
Uploads new/changed local files and never deletes cloud files. A file that
differs on both sides is a conflict; by default push aborts and lists them
(like git rejecting a push when the remote is ahead — pull first). Deletions
are not propagated (see #862).
Examples:
bm cloud push --name research
bm cloud push --name research --dry-run
bm cloud push --name research --on-conflict keep-local
"""
_run_directional_transfer(
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@cloud_app.command("bisync")
def bisync_project_command(
name: str = typer.Option(..., "--name", help="Project name to bisync"),
name: str = typer.Option(..., "--name", "--project", help="Project name to bisync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
@@ -256,7 +467,7 @@ def bisync_project_command(
@cloud_app.command("check")
def check_project_command(
name: str = typer.Option(..., "--name", help="Project name to check"),
name: str = typer.Option(..., "--name", "--project", help="Project name to check"),
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
) -> None:
"""Verify file integrity between local and cloud.
@@ -395,9 +606,15 @@ def setup_project_sync(
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
# Lead with the Team-safe additive commands (work on any workspace); the
# `sync`/`bisync` mirrors are Personal-workspace-only.
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud sync --name {name} --dry-run")
console.print(f" 2. Sync: bm cloud sync --name {name}")
console.print(f" 1. Preview a pull: bm cloud pull --name {name} --dry-run")
console.print(f" 2. Fetch from cloud: bm cloud pull --name {name}")
console.print(f" 3. Upload local changes: bm cloud push --name {name}")
console.print(
f" Personal workspaces can also mirror with: bm cloud bisync --name {name} --resync"
)
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
raise typer.Exit(1)
@@ -11,10 +11,10 @@ Replaces tenant-wide sync with project-scoped workflows.
import re
import subprocess
from dataclasses import dataclass
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Callable, Optional, Protocol
from pathlib import Path, PurePosixPath
from typing import Callable, Literal, Optional, Protocol
from loguru import logger
from rich.console import Console
@@ -42,6 +42,7 @@ TIGRIS_CONSISTENCY_HEADERS = [
class RunResult(Protocol):
returncode: int
stdout: str
stderr: str
RunFunc = Callable[..., RunResult]
@@ -184,6 +185,91 @@ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
# --- Directional transfer primitives (push / pull) ---
#
# These power the Team-safe `bm cloud push` / `bm cloud pull` commands. Unlike
# the mirror operations (`sync`/`bisync`), they use `rclone copy` so they never
# delete on the destination, and conflicts are surfaced to the caller rather
# than silently resolved. See issue #858 for the full design rationale.
# push = local -> cloud, pull = cloud -> local.
TransferDirection = Literal["push", "pull"]
# How a directional transfer treats files that differ on both sides. "fail" is
# the safe default: the caller is expected to abort before any transfer runs.
ConflictStrategy = Literal["fail", "keep-local", "keep-cloud", "keep-both"]
@dataclass
class TransferPlan:
"""Classification of how local and cloud differ for a directional transfer.
Built from ``rclone check --combined``. Paths are relative to the project
root. ``conflicts`` are files present on both sides with differing content —
without a sync baseline (see #862) every divergence is a conflict, because
we cannot tell a teammate's edit from a stale local copy.
"""
new: list[str] = field(default_factory=list) # only on source → safe to bring over
conflicts: list[str] = field(default_factory=list) # differ on both sides
dest_only: list[str] = field(default_factory=list) # only on destination → left untouched
errors: list[str] = field(default_factory=list) # rclone could not read/hash
def _transfer_endpoints(project: SyncProject, bucket_name: str) -> tuple[str, str]:
"""Return (local_path, remote_path) strings for a project's transfer.
Raises:
RcloneError: If the project has no local_sync_path configured.
"""
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = str(Path(project.local_sync_path).expanduser())
remote_path = get_project_remote(project, bucket_name)
return local_path, remote_path
def _build_transfer_cmd(
operation: str,
source: str,
dest: str,
*,
filter_path: Path,
dry_run: bool,
verbose: bool,
extra_flags: tuple[str, ...] = (),
) -> list[str]:
"""Build an rclone sync/copy command with the shared Basic Memory flags.
All directional transfers share the same tail: Tigris consistency headers,
the .bmignore filter, and --local-no-preallocate (a no-op when local is the
source, required when local is the destination on pull — see rclone#6801).
"""
cmd = [
"rclone",
operation,
source,
dest,
*TIGRIS_CONSISTENCY_HEADERS,
"--filter-from",
str(filter_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
*extra_flags,
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
return cmd
def project_sync(
project: SyncProject,
bucket_name: str,
@@ -219,24 +305,199 @@ def project_sync(
remote_path = get_project_remote(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
cmd = [
"rclone",
cmd = _build_transfer_cmd(
"sync",
str(local_path),
remote_path,
filter_path=filter_path,
dry_run=dry_run,
verbose=verbose,
)
result = run(cmd, text=True)
return result.returncode == 0
def _parse_check_combined(output: str) -> TransferPlan:
"""Parse ``rclone check --combined`` output into a TransferPlan.
rclone emits one prefixed line per path (src is the transfer source):
``=`` identical, ``+`` only on src, ``-`` only on dst, ``*`` differ,
``!`` error reading/hashing. We ignore identical files.
"""
plan = TransferPlan()
for line in output.splitlines():
symbol, _, path = line.partition(" ")
path = path.strip()
if not path:
continue
if symbol == "+":
plan.new.append(path)
elif symbol == "*":
plan.conflicts.append(path)
elif symbol == "-":
plan.dest_only.append(path)
elif symbol == "!":
plan.errors.append(path)
# "=" (identical) is intentionally dropped.
return plan
def project_diff(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
*,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> TransferPlan:
"""Classify how local and cloud differ for a push/pull, without transferring.
Uses ``rclone check`` (content comparison) so the caller can surface
conflicts before any data moves. The source side depends on direction:
pull compares cloud→local, push compares local→cloud.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
# Source/dest order matters: rclone check reports "+" for files only on the
# source, which is what we want to bring over.
source, dest = (remote_path, local_path) if direction == "pull" else (local_path, remote_path)
cmd = [
"rclone",
"check",
source,
dest,
*TIGRIS_CONSISTENCY_HEADERS,
"--filter-from",
str(filter_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
"--combined",
"-",
]
# rclone check exits non-zero when files differ — that's expected here, so we
# parse the combined listing rather than trusting the return code.
result = run(cmd, capture_output=True, text=True)
plan = _parse_check_combined(result.stdout)
# Trigger: non-zero exit AND the combined listing produced no entries at all.
# Why: a difference always yields +/-/*/! lines, so an empty listing on a
# non-zero exit means the check itself failed (auth, missing remote, network,
# bad filter) rather than finding zero differences. Without this guard the
# caller would see an empty plan, transfer nothing, and report success.
# Outcome: fail fast with rclone's stderr instead of a silent no-op.
if result.returncode != 0 and not (plan.new or plan.conflicts or plan.dest_only or plan.errors):
detail = result.stderr.strip() or f"rclone check exited with code {result.returncode}"
raise RcloneError(f"Failed to compare {project.name} with cloud: {detail}")
return plan
def project_copy(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
*,
overwrite: bool,
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> bool:
"""Additive transfer via ``rclone copy`` — never deletes on the destination.
Trigger: ``overwrite=False`` adds ``--ignore-existing`` so files already on
the destination are left as-is (used when the destination side wins a
conflict, and for the no-conflict fast path).
Why: keeps the loser's bytes intact unless the caller explicitly chose to
overwrite, matching the "no surprises" contract.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
source, dest = (remote_path, local_path) if direction == "pull" else (local_path, remote_path)
# Overwrite mode compares by checksum so the transfer decision matches
# project_diff's content-based conflict detection (rclone check). Without
# --checksum, copy's default size+modtime comparison could skip a file the
# diff flagged as a conflict (same size, destination not older) — silently
# ignoring the user's explicit keep-cloud/keep-local choice. New-only mode
# uses --ignore-existing, which skips by existence so the comparison basis
# does not matter.
extra_flags = ("--checksum",) if overwrite else ("--ignore-existing",)
cmd = _build_transfer_cmd(
"copy",
source,
dest,
filter_path=filter_path,
dry_run=dry_run,
verbose=verbose,
extra_flags=extra_flags,
)
result = run(cmd, text=True)
return result.returncode == 0
def _conflict_copy_name(rel_path: str, suffix: str) -> str:
"""Insert a ``.conflict-<suffix>`` marker before the extension of a rel path."""
p = PurePosixPath(rel_path)
return str(p.with_name(f"{p.stem}.conflict-{suffix}{p.suffix}"))
def project_copy_file(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
source_rel_path: str,
dest_rel_path: str,
*,
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
) -> bool:
"""Copy a single file from source to destination under a (possibly renamed) path.
Used for the ``keep-both`` strategy: the incoming version is written beside
the destination's own copy as ``name.conflict-<date>`` so nothing is lost.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
source_root, dest_root = (
(remote_path, local_path) if direction == "pull" else (local_path, remote_path)
)
cmd = [
"rclone",
"copyto",
f"{source_root}/{source_rel_path}",
f"{dest_root}/{dest_rel_path}",
*TIGRIS_CONSISTENCY_HEADERS,
# Matches _build_transfer_cmd: on pull this writes the conflict copy to
# the local filesystem, where this prevents NUL byte padding on virtual
# filesystems (e.g. Google Drive File Stream). See rclone/rclone#6801.
"--local-no-preallocate",
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
@@ -244,6 +505,72 @@ def project_sync(
return result.returncode == 0
def _strategy_overwrites_dest(direction: TransferDirection, strategy: ConflictStrategy) -> bool:
"""True when the strategy lets the source side overwrite the destination.
The source side is cloud on pull, local on push. "keep-cloud" wins on pull,
"keep-local" wins on push; otherwise the destination is preserved.
"""
if strategy == "keep-cloud":
return direction == "pull"
if strategy == "keep-local":
return direction == "push"
return False # "fail" (no conflicts) and "keep-both" never overwrite existing dest files
def project_transfer(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
plan: TransferPlan,
*,
strategy: ConflictStrategy = "fail",
conflict_suffix: str = "",
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> bool:
"""Execute a directional transfer for the chosen conflict strategy.
Callers detect conflicts with ``project_diff`` first and abort when
``strategy == "fail"`` and conflicts exist; this function assumes that gate
has already passed and applies the resolution.
"""
# keep-both: preserve the destination's version and drop the incoming one
# beside it as a conflict copy, then do an additive (new-only) pass.
if strategy == "keep-both":
for rel_path in plan.conflicts:
dest_rel = _conflict_copy_name(rel_path, conflict_suffix)
copied = project_copy_file(
project,
bucket_name,
direction,
rel_path,
dest_rel,
dry_run=dry_run,
verbose=verbose,
run=run,
is_installed=is_installed,
)
if not copied:
return False
overwrite = _strategy_overwrites_dest(direction, strategy)
return project_copy(
project,
bucket_name,
direction,
overwrite=overwrite,
dry_run=dry_run,
verbose=verbose,
run=run,
is_installed=is_installed,
filter_path=filter_path,
)
def project_bisync(
project: SyncProject,
bucket_name: str,
+12
View File
@@ -98,6 +98,18 @@ def mcp(
if transport == "stdio":
threading.Thread(target=_run_background_auto_update, daemon=True).start()
# Trigger: MCP server startup on the Postgres backend, before the transport
# creates its event loop.
# Why: the watcher/lifespan path runs startup migrations + engine.dispose() on
# asyncpg, which races stdlib asyncio teardown and crashes the container loop
# (#831/#877). uvloop's C scheduler structurally avoids that race and must own
# the loop policy before the loop is created. No-op for SQLite.
# Outcome: `basic-memory mcp` on Postgres runs on uvloop. (The CLI callback also
# installs it; this keeps the server startup seam explicit and self-contained.)
from basic_memory.db import maybe_install_uvloop
maybe_install_uvloop(ConfigManager().config)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
+70 -5
View File
@@ -1,8 +1,9 @@
"""Status command for basic-memory CLI."""
import asyncio
import json
from typing import Set, Dict
from typing import Annotated, Optional
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
@@ -142,20 +143,58 @@ def display_changes(
console.print(Panel(tree, expand=False))
class StatusTimeout(Exception):
"""Raised when --wait does not reach a synced state before the deadline."""
async def run_status(
project: Optional[str] = None,
wait: bool = False,
timeout: float = 30.0,
poll_interval: float = 0.5,
) -> tuple[str, SyncReportResponse]:
"""Fetch sync status of files vs database.
When ``wait`` is False this performs a single live disk-vs-DB scan and
returns immediately. When ``wait`` is True it polls until the project has
no pending changes (``sync_report.total == 0``) or the timeout elapses.
Returns (project_name, sync_report) for the caller to render.
Raises:
StatusTimeout: If ``wait`` is True and the deadline passes before the
project reaches a synced state.
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
# Reuse a single client/context across polls so we don't reconnect each loop.
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
sync_report = await ProjectClient(client).get_status(project_item.external_id)
return project_item.name, sync_report
project_client = ProjectClient(client)
# Trigger: caller did not request --wait
# Why: preserve the original single-scan behavior for the common case
# Outcome: one status scan, returned as-is
if not wait:
sync_report = await project_client.get_status(project_item.external_id)
return project_item.name, sync_report
# Trigger: --wait requested
# Why: callers (bulk imports, benchmarks, tests) need to block until the
# index has caught up instead of polling externally
# Outcome: poll get_status until total == 0 or the deadline is reached
deadline = time.monotonic() + timeout
while True:
sync_report = await project_client.get_status(project_item.external_id)
if sync_report.total == 0:
return project_item.name, sync_report
if time.monotonic() >= deadline:
raise StatusTimeout(
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
)
await asyncio.sleep(poll_interval)
@app.command()
@@ -166,6 +205,10 @@ def status(
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
wait: bool = typer.Option(
False, "--wait", help="Block until indexing is complete (no pending changes)"
),
timeout: float = typer.Option(30.0, "--timeout", help="Max seconds to wait when --wait is set"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -174,11 +217,21 @@ def status(
"""Show sync status between files and database.
Use --json for machine-readable output.
Use --wait to block until indexing is complete (e.g. after a bulk import);
combine with --timeout to bound the wait. On timeout the command exits 1.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
# before the try/except so typer renders a clean usage error (exit 2).
# Outcome: reject it up front with a clear parameter error.
if wait and timeout < 0:
raise typer.BadParameter("--timeout must be >= 0", param_hint="'--timeout'")
try:
validate_routing_flags(local, cloud)
# Trigger: no explicit routing flag provided
@@ -189,12 +242,24 @@ def status(
if not local and not cloud:
local = True
with force_routing(local=local, cloud=cloud):
project_name, sync_report = run_with_cleanup(run_status(project))
project_name, sync_report = run_with_cleanup(
run_status(project, wait=wait, timeout=timeout)
)
if json_output:
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
else:
display_changes(project_name, "Status", sync_report, verbose)
except StatusTimeout as e:
# Trigger: --wait deadline passed before the project finished indexing
# Why: callers depend on exit code 1 to detect that indexing did not
# complete in time, while still getting a clear machine/human message
# Outcome: emit the timeout message (JSON-shaped under --json) and exit 1
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except (ValueError, ToolError) as e:
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
+140 -1
View File
@@ -15,6 +15,7 @@ from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import delete_note as mcp_delete_note
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
@@ -40,6 +41,26 @@ def _print_json(result: Any) -> None:
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
def _delete_note_failure_message(result: dict[str, Any]) -> str | None:
"""Return the CLI failure message for delete-note JSON results, if any."""
error = result.get("error")
if error:
return str(error)
failed_deletes = result.get("failed_deletes")
# Trigger: directory deletion can partially fail without raising from the service.
# Why: cleanup scripts need a non-zero exit when files remain undeleted.
# Outcome: the CLI fails even if older MCP JSON did not include an error field.
if (
result.get("is_directory") is True
and isinstance(failed_deletes, int)
and failed_deletes > 0
):
return f"Directory delete incomplete: {failed_deletes} file(s) failed"
return None
# --- Commands ---
@@ -56,6 +77,16 @@ def write_note(
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
note_type: Annotated[
str,
typer.Option(
"--type",
help=(
"Note type stored in frontmatter (e.g. 'guide', 'report'). "
"A 'type:' in the note's own content frontmatter takes precedence."
),
),
] = "note",
project: Annotated[
Optional[str],
typer.Option(
@@ -69,6 +100,11 @@ def write_note(
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
] = None,
overwrite: bool = typer.Option(
False,
"--overwrite",
help="Replace an existing note on conflict (matches MCP write_note overwrite=True)",
),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -79,7 +115,9 @@ def write_note(
Examples:
bm tool write-note --title "My Note" --folder "notes" --content "Note content"
bm tool write-note --title "My Guide" --folder "notes" --content "..." --type guide
echo "content" | bm tool write-note --title "My Note" --folder "notes"
bm tool write-note --title "My Note" --folder "notes" --overwrite
bm tool write-note --title "My Note" --folder "notes" --local
"""
try:
@@ -111,9 +149,23 @@ def write_note(
project=project,
project_id=project_id,
tags=tags,
note_type=note_type,
overwrite=overwrite,
output_format="json",
)
)
# MCP tool returns an error field on failure in JSON mode (e.g.
# NOTE_ALREADY_EXISTS on a blocked overwrite, SECURITY_VALIDATION_ERROR).
# Trigger: result carries a non-empty `error`.
# Why: parity with delete-note/edit-note/search-notes so exit-code-driven
# scripts detect a failed/blocked write instead of seeing exit 0.
# Outcome: print the error to stderr and exit non-zero.
if isinstance(result, dict) and result.get("error"):
typer.echo(f"Error: {result['error']}", err=True)
_print_json(result)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
@@ -167,6 +219,19 @@ def read_note(
output_format="json",
)
)
# MCP tool returns an error field on failure in JSON mode (e.g.
# SECURITY_VALIDATION_ERROR on a path-traversal identifier). A genuine
# not-found returns null fields with no `error` key, so it still exits 0.
# Trigger: result carries a non-empty `error`.
# Why: parity with edit-note/delete-note/search-notes so a blocked read
# surfaces a non-zero exit instead of looking like success.
# Outcome: print the error to stderr and exit non-zero.
if isinstance(result, dict) and result.get("error"):
typer.echo(f"Error: {result['error']}", err=True)
_print_json(result)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
@@ -178,6 +243,66 @@ def read_note(
raise
@tool_app.command("delete-note")
def delete_note(
identifier: str,
is_directory: bool = typer.Option(
False, "--is-directory", help="Delete a directory instead of a single note"
),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Delete a note or directory from the knowledge base.
Examples:
bm tool delete-note notes/old-draft
bm tool delete-note docs/archive --is-directory
"""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_delete_note(
identifier=identifier,
is_directory=is_directory,
project=project,
project_id=project_id,
output_format="json",
)
)
if isinstance(result, dict):
failure_message = _delete_note_failure_message(result)
if failure_message:
typer.echo(f"Error: {failure_message}", err=True)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during delete_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def edit_note(
identifier: str,
@@ -324,7 +449,9 @@ def recent_activity(
"7d", "--timeframe", help="Timeframe filter (e.g., '7d', '1 week')"
),
page: int = typer.Option(1, "--page", help="Page number for pagination"),
page_size: int = typer.Option(50, "--page-size", help="Number of results per page"),
# Match the MCP recent_activity default (page_size=10) so identical default
# invocations return the same number of rows from CLI and MCP.
page_size: int = typer.Option(10, "--page-size", help="Number of results per page"),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
@@ -409,6 +536,16 @@ def search_notes(
help="Filter by search item type: entity, observation, relation (repeatable)",
),
] = None,
categories: Annotated[
Optional[List[str]],
typer.Option(
"--category",
help=(
"Filter observation results to exact categories (repeatable); "
"pair with --entity-type observation"
),
),
] = None,
meta: Annotated[
Optional[List[str]],
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
@@ -443,6 +580,7 @@ def search_notes(
bm tool search-notes --permalink "specs/*"
bm tool search-notes --tag python --tag async
bm tool search-notes --meta status=draft
bm tool search-notes "auth" --entity-type observation --category requirement
"""
try:
validate_routing_flags(local, cloud)
@@ -508,6 +646,7 @@ def search_notes(
page_size=page_size,
note_types=note_types,
entity_types=entity_types,
categories=categories,
metadata_filters=metadata_filters,
tags=tags,
status=status,
@@ -0,0 +1,23 @@
"""Top-level `workspace` stub that redirects users to `bm cloud workspace`."""
import typer
from basic_memory.cli.app import app
# Trigger: user runs `bm workspace`, `bm workspace list`, etc.
# Why: workspace verbs live under `bm cloud workspace`; a bare top-level miss
# only emits Typer's terse "No such command 'workspace'." with exit 2.
# Outcome: allow_extra_args + ignore_unknown_options absorb any trailing tokens
# (e.g. `list`, `set-default foo`) so every invocation reaches this body and
# prints actionable guidance instead of being rejected as bad usage.
@app.command(
"workspace",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)
def workspace_stub(ctx: typer.Context) -> None:
"""Point users to the real workspace verbs under `bm cloud workspace`."""
typer.echo("'bm workspace' is not a command. Workspace verbs live under 'bm cloud workspace':")
typer.echo(" bm cloud workspace list")
typer.echo(" bm cloud workspace set-default <name>")
raise typer.Exit(1)
+1
View File
@@ -31,6 +31,7 @@ if not _version_only_invocation(sys.argv[1:]):
status,
tool,
update,
workspace,
)
warnings.filterwarnings("ignore") # pragma: no cover
+27 -1
View File
@@ -248,7 +248,19 @@ class BasicMemoryConfig(BaseSettings):
)
semantic_embedding_dimensions: int | None = Field(
default=None,
description="Embedding vector dimensions. Auto-detected from provider if not set (384 for FastEmbed, 1536 for OpenAI).",
description=(
"Embedding vector dimensions. Uses provider defaults when unset "
"(384 for FastEmbed, 1536 for OpenAI and LiteLLM OpenAI default); "
"required for custom LiteLLM models."
),
)
semantic_embedding_forward_dimensions: bool | None = Field(
default=None,
description=(
"LiteLLM-only override for sending semantic_embedding_dimensions as a "
"provider-side output-size request parameter. When unset, Basic Memory "
"auto-detects model strings such as text-embedding-3."
),
)
# Trigger: full local rebuilds spend most of their time waiting behind shared
# embed flushes, not constructing vectors themselves.
@@ -266,6 +278,20 @@ class BasicMemoryConfig(BaseSettings):
description="Maximum number of concurrent provider requests for batched embedding generation when the active provider supports request-level concurrency.",
gt=0,
)
semantic_embedding_document_input_type: str | None = Field(
default=None,
description=(
"Optional LiteLLM input_type for indexed document/passages. "
"Use with asymmetric embedding models such as Cohere or NVIDIA retrieval models."
),
)
semantic_embedding_query_input_type: str | None = Field(
default=None,
description=(
"Optional LiteLLM input_type for search queries. "
"Use with asymmetric embedding models such as Cohere or NVIDIA retrieval models."
),
)
semantic_embedding_sync_batch_size: int = Field(
default=2,
description="Batch size for vector sync orchestration flushes.",
+63 -6
View File
@@ -1,7 +1,7 @@
import asyncio
import os
import sys
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
@@ -39,6 +39,44 @@ from basic_memory.repository.sqlite_search_repository import SQLiteSearchReposit
if sys.platform == "win32": # pragma: no cover
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
def maybe_install_uvloop(config: BasicMemoryConfig) -> bool:
"""Install the uvloop event-loop policy for the Postgres backend.
Trigger: process entrypoint starting with database_backend == postgres,
uvloop importable, and a non-Windows platform.
Why: asyncpg engine teardown (engine.dispose()) races the stdlib asyncio
loop shutdown and surfaces "IndexError: pop from an empty deque" from
base_events._run_once (see #831/#877). uvloop's C scheduler has no
self._ready.popleft() codepath, so that class of crash cannot fire under it.
Outcome: Postgres deployments run on uvloop; SQLite users keep the default
loop (no behavior change, smaller blast radius). Must run before the event
loop is created, i.e. before asyncio.run().
Returns:
True if the uvloop policy was installed, False otherwise.
"""
# uvloop is not available on Windows; the default loop already differs there.
if sys.platform == "win32": # pragma: no cover
return False
# Limit the change to the backend that actually hits the asyncpg dispose race.
if config.database_backend != DatabaseBackend.POSTGRES:
return False
# Deferred import: uvloop is an optional, platform-gated dependency and the
# default (SQLite) path must not require it to be installed.
try:
import uvloop
except ImportError: # pragma: no cover
logger.warning("uvloop not available - using default event loop for Postgres backend")
return False
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logger.info("Installed uvloop event-loop policy for Postgres backend")
return True
# Module level state
_engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
@@ -320,7 +358,15 @@ async def shutdown_db() -> None: # pragma: no cover
global _engine, _session_maker
if _engine:
await _engine.dispose()
# Trigger: teardown can run while the surrounding task is being cancelled
# (e.g. lifespan shutdown, unshielded CLI cleanup).
# Why: a cancellation landing mid-dispose surfaces the asyncpg
# "IndexError: pop from an empty deque" race (#831/#877); shielding lets
# dispose finish atomically, and suppressing CancelledError keeps a
# cancelled shutdown from re-raising the underlying race.
# Outcome: connections always close cleanly even under cancellation.
with suppress(asyncio.CancelledError):
await asyncio.shield(_engine.dispose())
_engine = None
_session_maker = None
@@ -364,7 +410,14 @@ async def engine_session_factory(
yield created_engine, created_session_maker
finally:
await created_engine.dispose()
# Trigger: context-manager teardown can run while the surrounding task is
# being cancelled (e.g. a test aborting mid-fixture).
# Why: on the asyncpg backend a cancellation landing mid-dispose surfaces
# the "IndexError: pop from an empty deque" race (#831/#877); shield the
# dispose and suppress CancelledError to match the other dispose seams.
# Outcome: the per-context engine always disposes cleanly under cancellation.
with suppress(asyncio.CancelledError):
await asyncio.shield(created_engine.dispose())
# Only clear module-level globals if they still point to this context's
# engine/session. This avoids clobbering newer globals from other callers.
@@ -433,7 +486,11 @@ async def run_migrations(
# Trigger: run_migrations() created a temporary engine while module-level
# session maker was not initialized.
# Why: temporary aiosqlite worker threads can outlive CLI command execution
# and block process shutdown if the engine is not disposed.
# Outcome: always dispose temporary engines after migration work completes.
# and block process shutdown if the engine is not disposed. On the asyncpg
# backend a cancellation landing mid-dispose surfaces the same "IndexError:
# pop from an empty deque" race as the other dispose seams (#831/#877), so
# shield the dispose and suppress CancelledError to match them.
# Outcome: always dispose temporary engines cleanly, even under cancellation.
if temp_engine is not None:
await temp_engine.dispose()
with suppress(asyncio.CancelledError):
await asyncio.shield(temp_engine.dispose())
+7 -1
View File
@@ -493,8 +493,14 @@ class BatchIndexer:
async def resolve_relation(relation: Relation) -> int:
async with semaphore:
try:
# strict=True for deferred resolution: only fill in to_id on an
# exact permalink/title/file_path match. Fuzzy fallback would silently
# resolve ambiguous links to whichever entity shares tokens with the
# link text, mismatching this with the sync_service forward-reference
# path and producing confidently-wrong graph edges. See
# sync_service.resolve_forward_references for the same change.
resolved_entity = await self.entity_service.link_resolver.resolve_link(
relation.to_name
relation.to_name, strict=True
)
if resolved_entity is None or resolved_entity.id == relation.from_id:
return 0
@@ -209,6 +209,18 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
# Validate pagination arguments before they reach the context service.
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative).
# Why: a non-positive page_size flows into context_service as limit, where the
# primary slice does primary = primary[:limit] — so limit=0 truncates the
# requested entity to [] and the caller's valid memory:// lookup silently
# returns primary_count=0. Mirrors recent_activity's guard for consistency.
# Outcome: caller gets an explicit ValueError instead of a dropped primary result.
if page < 1:
raise ValueError(f"page must be >= 1, got {page}")
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
+9 -1
View File
@@ -315,14 +315,22 @@ async def delete_note(
)
result = await knowledge_client.delete_directory(directory_identifier)
if output_format == "json":
return {
response = {
"deleted": result.failed_deletes == 0,
"is_directory": True,
"identifier": identifier,
"total_files": result.total_files,
"successful_deletes": result.successful_deletes,
"failed_deletes": result.failed_deletes,
"deleted_files": result.deleted_files,
"errors": [error.model_dump() for error in result.errors],
}
if result.failed_deletes > 0:
response["error"] = (
"Directory delete incomplete: "
f"{result.failed_deletes} of {result.total_files} file(s) failed"
)
return response
# Build success message for directory delete
result_lines = [
+130 -22
View File
@@ -10,7 +10,7 @@ from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.utils import validate_project_path
@@ -37,22 +37,35 @@ async def _detect_cross_project_move_attempt(
project_list = await project_client.list_projects()
project_names = [p.name.lower() for p in project_list.projects]
# Check if destination path contains any project names
dest_lower = destination_path.lower()
path_parts = dest_lower.split("/")
path_parts = [part for part in dest_lower.split("/") if part]
# Look for project names in the destination path
for part in path_parts:
if part in project_names and part != current_project.lower():
# Found a different project name in the path
# --- Detection 1: leading segment is a known project name ---
# Trigger: the first path segment matches a different project's name.
# Why: a routing-style destination like "other-project/file.md" expresses an
# intent to move into another project, which move_note cannot do — it
# would silently create a same-project nested folder instead.
# Outcome: reject with cross-project guidance rather than fake success.
if path_parts:
leading = path_parts[0]
if leading in project_names and leading != current_project.lower():
matching_project = next(
p.name for p in project_list.projects if p.name.lower() == part
p.name for p in project_list.projects if p.name.lower() == leading
)
return _format_cross_project_error_response(
identifier, destination_path, current_project, matching_project
)
# No other cross-project patterns detected
# NOTE: a "<seg>/projects/<seg>/..." structural heuristic was removed here.
# Why: matching any destination whose 2nd segment is literally "projects" is
# fundamentally ambiguous — it cannot distinguish the cloud workspace
# routing shape from a legitimate same-project nested folder like
# "notes/projects/2025/file.md" or "work/projects/q1/report.md". The
# heuristic produced false CROSS_PROJECT_MOVE_NOT_SUPPORTED rejections for
# those common layouts.
# Outcome: cross-workspace routing that does not match a known project name (above)
# now falls through to the move and is caught honestly by the MOVE_OUTCOME_MISMATCH
# backstop if the result lands somewhere other than the requested path.
except Exception as e:
# If we can't detect, don't interfere with normal error handling
@@ -633,12 +646,37 @@ list_directory("{identifier}")
move_note("path/to/file.md", "{destination_path}/file.md")
```"""
# Check for potential cross-project move attempts (file moves only)
cross_project_error = await _detect_cross_project_move_attempt(
client, identifier, destination_path, active_project.name
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient
# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
# --- Normalize the identifier for memory:// URLs ---
# Trigger: caller passed a "memory://..." URL (the docstring advertises this, and
# read_note/edit_note/delete_note all accept it).
# Why: resolve_entity / the /resolve endpoint expect a bare permalink or title;
# the literal "memory://" scheme prefix is not stripped by link resolution and
# 404s. resolve_project_and_path strips the prefix and normalizes the path the
# same way the sibling tools do.
# Outcome: move_note resolves memory:// URLs identically to read/edit/delete.
source_project, resolved_identifier, _ = await resolve_project_and_path(
client, identifier, active_project.name, context
)
if cross_project_error:
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
# Trigger: a memory:// identifier whose project prefix resolves to a DIFFERENT project
# than the one move_note is operating on (e.g. "memory://other-project/...").
# Why: get_project_client already bound knowledge_client to the active project, so the
# resolve_entity below runs against the active project regardless of the URL's
# project. Honoring the cross-project prefix would misroute (path normalized for
# the other project, looked up in the active one); move_note cannot move across
# projects.
# Outcome: reject up front with the cross-project guidance instead of misrouting.
if source_project.external_id != active_project.external_id:
logger.info(
f"Move rejected: source '{identifier}' resolves to project "
f"'{source_project.name}', not the active project '{active_project.name}'"
)
if output_format == "json":
return {
"moved": False,
@@ -649,13 +687,9 @@ move_note("path/to/file.md", "{destination_path}/file.md")
"destination": destination_path,
"error": "CROSS_PROJECT_MOVE_NOT_SUPPORTED",
}
return cross_project_error
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient
# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
return _format_cross_project_error_response(
identifier, destination_path, active_project.name, source_project.name
)
# Resolve once and reuse the entity ID across extension validation and move.
source_ext = "md" # Default to .md if we can't determine source extension
@@ -666,7 +700,9 @@ move_note("path/to/file.md", "{destination_path}/file.md")
"""Resolve and cache the source entity ID for the duration of this move."""
nonlocal resolved_entity_id
if resolved_entity_id is None:
resolved_entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
resolved_entity_id = await knowledge_client.resolve_entity(
resolved_identifier, strict=True
)
return resolved_entity_id
try:
@@ -756,6 +792,31 @@ The destination folder '{destination_folder}' is not allowed - paths must stay w
move_note("{identifier}", destination_folder="notes")
```"""
# --- Cross-boundary intent guard (file moves only) ---
# Trigger: destination_path now holds the real combined target, whether it came
# from destination_path or was resolved from destination_folder above.
# Why: detection must run AFTER folder resolution — running it earlier (when a
# caller used destination_folder) saw an empty destination_path and skipped
# entirely (#881 Gap 3).
# Outcome: a cross-workspace/cross-project routing destination is rejected with
# guidance instead of silently degrading to a same-project nested folder.
cross_project_error = await _detect_cross_project_move_attempt(
client, identifier, destination_path, active_project.name
)
if cross_project_error:
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "CROSS_PROJECT_MOVE_NOT_SUPPORTED",
}
return cross_project_error
# Validate that destination path includes a file extension
if "." not in destination_path or not destination_path.split(".")[-1]:
logger.warning(f"Move failed - no file extension provided: {destination_path}")
@@ -839,6 +900,53 @@ move_note("{identifier}", destination_folder="notes")
# Call the move API using KnowledgeClient
result = await knowledge_client.move_entity(resolved_entity_id, destination_path)
# --- Outcome validation (honest success backstop) ---
# Trigger: the resulting file_path differs from the destination the caller
# requested.
# Why: move_entity stores the path relative to the *current* project root, so
# a cross-boundary intent silently degrades into a same-project nested
# folder. The old code reported "✅ moved successfully" regardless,
# misleading the agent (#881 Gap 2). This is the robust backstop behind
# the up-front detection: any divergence the caller did not ask for
# surfaces as a failure rather than a fake success.
# Outcome: report failure with the actual landing path instead of "✅".
normalized_requested = PureWindowsPath(destination_path).as_posix().strip("/")
normalized_actual = PureWindowsPath(result.file_path).as_posix().strip("/")
if normalized_actual != normalized_requested:
logger.warning(
f"Move outcome diverged from intent: requested={destination_path} "
f"actual={result.file_path}"
)
if output_format == "json":
return {
"moved": False,
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"source": identifier,
"destination": destination_path,
"error": "MOVE_OUTCOME_MISMATCH",
}
return dedent(f"""
# Move Failed - Unexpected Result Location
The move of '{identifier}' did not land at the requested destination.
**Requested:** `{destination_path}`
**Actual:** `{result.file_path}`
This usually means the destination referenced a different
workspace/project, which move_note cannot do — notes can only be
moved within the same project. To move content between projects:
```
read_note("{result.file_path}")
write_note("Title", "content", "folder", project="target-project")
delete_note("{result.file_path}", project="{active_project.name}")
```
""").strip()
if output_format == "json":
return {
"moved": True,
+11 -5
View File
@@ -300,14 +300,20 @@ async def recent_activity(
else:
# Project-Specific Mode: Get activity for specific project
# Uses get_project_client() for per-project routing (local vs cloud)
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
)
async with get_project_client(resolved_project, context=context, project_id=project_id) as (
client,
active_project,
):
# Trigger: caller routed by project_id (a UUID), so resolved_project holds the
# raw UUID rather than a human-readable name.
# Why: active_project.name is the canonical, display-safe project name regardless
# of whether routing was by name or by external_id.
# Outcome: logs and the formatted text header always show the project name.
logger.info(
f"Getting recent activity from project {active_project.name}: "
f"type={type}, depth={depth}, timeframe={timeframe}"
)
response = await call_get(
client,
f"/v2/projects/{active_project.external_id}/memory/recent",
@@ -319,7 +325,7 @@ async def recent_activity(
return _extract_recent_rows(activity_data)
# Format project-specific mode output
return _format_project_output(resolved_project, activity_data, timeframe, type, page)
return _format_project_output(active_project.name, activity_data, timeframe, type, page)
async def _get_project_activity(
+102 -7
View File
@@ -83,6 +83,47 @@ def _format_search_error_response(
`search_notes("{project}", "{query}", search_type="{search_type}")`
""").strip()
# Corrupt/missing FastEmbed model cache (interrupted download leaves a partial
# snapshot missing model_optimized.onnx; the ONNX runtime then raises NO_SUCHFILE).
# Basic Memory self-heals by re-downloading on the next load, but if the user still
# hits this, point them at the cache dir to clear manually and offer a text fallback.
error_lower = error_message.lower()
# "load model from" is the exact ONNX phrasing ("Load model from <path>.onnx failed").
# The looser "load model" matched unrelated errors, so we keep only the specific phrase
# alongside the onnxruntime / no_suchfile / model_optimized.onnx fingerprints.
if (
"onnxruntime" in error_lower
or "no_suchfile" in error_lower
or "model_optimized.onnx" in error_lower
or "load model from" in error_lower
):
# Deferred import: keeps the repository layer out of the tool's import graph
# (matches the SearchClient deferral below) and is only needed on this error path.
from basic_memory.repository.embedding_provider_factory import _resolve_cache_dir
try:
cache_dir = _resolve_cache_dir(get_container().config)
except RuntimeError:
cache_dir = _resolve_cache_dir(ConfigManager().config)
return dedent(f"""
# Search Failed - Embedding Model Missing or Corrupt
The local FastEmbed model could not be loaded for query '{query}': {error_message}
This usually means an earlier model download was interrupted and left an
incomplete file in the model cache.
## How to fix
1. Delete the FastEmbed model cache so it re-downloads on the next search:
`{cache_dir}`
2. Run your search again (the model downloads automatically on first use):
`search_notes("{project}", "{query}", search_type="{search_type}")`
## Workaround right now
- Use full-text search, which needs no embedding model:
`search_notes("{project}", "{query}", search_type="text")`
""").strip()
# FTS5 syntax errors
if "syntax error" in error_message.lower() or "fts5" in error_message.lower():
clean_query = (
@@ -114,7 +155,7 @@ def _format_search_error_response(
- Boolean NOT: `project NOT archived`
- Grouped: `(project OR planning) AND notes`
- Exact phrases: `"weekly standup meeting"`
- Content-specific: `tag:example` or `category:observation`
- Content-specific: `tag:example`
## Try again with:
```
@@ -181,7 +222,7 @@ def _format_search_error_response(
6. **Try advanced search patterns**:
- Tag search: `search_notes("{project}","tag:your-tag")`
- Category search: `search_notes("{project}","category:observation")`
- Observation category: `search_notes("{project}","{query}", entity_types=["observation"], categories=["requirement"])`
- Pattern matching: `search_notes("{project}","*{query}*", search_type="permalink")`
## Explore what content exists:
@@ -258,7 +299,8 @@ Error searching for '{query}': {error_message}
- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
- **Phrases**: `"exact phrase"`
- **Grouping**: `(term1 OR term2) AND term3`
- **Patterns**: `tag:example`, `category:observation`"""
- **Tags**: `tag:example`
- **Observation categories**: `entity_types=["observation"], categories=["requirement"]`"""
def _format_search_markdown(result: SearchResponse, project: str, query: str | None) -> str:
@@ -456,6 +498,7 @@ async def _search_all_projects(
output_format: Literal["text", "json"],
note_types: list[str],
entity_types: list[str],
categories: list[str],
after_date: str | None,
metadata_filters: dict[str, Any] | None,
tags: list[str] | None,
@@ -514,6 +557,7 @@ async def _search_all_projects(
output_format="json",
note_types=note_types or None,
entity_types=entity_types or None,
categories=categories or None,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
@@ -612,6 +656,14 @@ async def search_notes(
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead.",
] = None,
categories: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("categories", "category")),
"Filter observation results to these exact categories (e.g. ['requirement']). "
"Pair with entity_types=['observation'] to return only observations whose "
"category matches exactly — not every row mentioning the word.",
] = None,
# Time-filter naming varies wildly across APIs.
after_date: Annotated[
Optional[str],
@@ -666,7 +718,8 @@ async def search_notes(
### Content-Specific Searches
- `search_notes("research", "tag:example")` - Search within specific tags (if supported by content)
- `search_notes("work-project", "category:observation")` - Filter by observation categories
- `search_notes("work-project", "req", entity_types=["observation"], categories=["requirement"])`
- Return only observations whose category is exactly "requirement"
- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
@@ -684,6 +737,8 @@ async def search_notes(
- `search_notes("my-project", "query", note_types=["note"])` - Search only notes
- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
- `search_notes("research", "query", entity_types=["observation"], categories=["requirement"])`
- Filter observations to an exact category
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
@@ -735,6 +790,9 @@ async def search_notes(
"json" returns a machine-readable dictionary payload.
note_types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
categories: Optional list of observation categories for exact matching (e.g.,
["requirement"]). Pair with entity_types=["observation"] to return only
observations whose category matches exactly.
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
@@ -808,10 +866,26 @@ async def search_notes(
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
# Validate pagination arguments before they reach the API/repository layer.
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or a negative slice).
# Why: a non-positive page_size yields zero rows yet the router computes
# has_more = offset + len(results) < total, returning a misleading
# has_more=True with no reachable page; a negative page_size becomes an
# uncapped SQLite LIMIT. Mirrors recent_activity's guard so all navigation
# tools reject invalid pagination consistently.
# Outcome: caller gets an explicit ValueError instead of a silent bad payload.
if page < 1:
raise ValueError(f"page must be >= 1, got {page}")
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
entity_types = entity_types or []
# Categories are matched exactly against the indexed observation category,
# so preserve their original casing (unlike the lowercased note_types).
categories = categories or []
# Parse tag:<value> shorthand at tool level so it works with all search modes.
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
@@ -853,6 +927,7 @@ async def search_notes(
output_format=output_format,
note_types=note_types,
entity_types=entity_types,
categories=categories,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
@@ -876,8 +951,15 @@ async def search_notes(
has_query=bool(query and query.strip()),
note_type_filter_count=len(note_types),
entity_type_filter_count=len(entity_types),
category_filter_count=len(categories),
has_filters=bool(
metadata_filters or tags or status or note_types or entity_types or after_date
metadata_filters
or tags
or status
or note_types
or entity_types
or categories
or after_date
),
has_tags_filter=bool(tags),
has_status_filter=bool(status),
@@ -940,6 +1022,8 @@ async def search_notes(
# Add optional filters if provided (empty lists are treated as no filter)
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if categories:
search_query.categories = categories
if note_types:
search_query.note_types = note_types
if after_date:
@@ -965,7 +1049,8 @@ async def search_notes(
return (
"# No Search Criteria\n\n"
"Please provide at least one of: `query`, `metadata_filters`, "
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
"`tags`, `status`, `note_types`, `entity_types`, `categories`, "
"or `after_date`."
)
# Default to entity-level results to avoid returning individual
@@ -973,7 +1058,17 @@ async def search_notes(
# Applied after no_criteria() so that the implicit default doesn't
# mask a truly empty search request.
if not search_query.entity_types:
search_query.entity_types = [SearchItemType("entity")]
# Trigger: a category filter was supplied without an explicit
# entity_types.
# Why: categories only exist on observations — defaulting to "entity"
# (whose rows have NULL category) would AND a category filter against
# entity rows and return nothing, defeating a category-only search.
# Outcome: scope the implicit default to observations so
# search_notes(categories=[...]) returns the matching bullets.
if search_query.categories:
search_query.entity_types = [SearchItemType("observation")]
else:
search_query.entity_types = [SearchItemType("entity")]
logger.debug(
f"Search request: project={active_project.name} "
+14 -1
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
@@ -269,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()
)
@@ -3,18 +3,29 @@
import os
from threading import Lock
from loguru import logger
from basic_memory.config import BasicMemoryConfig, default_fastembed_cache_dir
from basic_memory.repository.embedding_provider import EmbeddingProvider
# Cache key fields are limited to values that change the *identity* of the loaded
# model (provider, model_name, dimensions, LiteLLM role/input-type/forward-dimension
# settings, batch/request knobs that affect the LiteLLM identity, and the resolved
# cache dir). Thread/parallel knobs are deliberately excluded — they change ONNX
# *execution* only, not the loaded weights. Including them caused #872: in a
# container/cgroup the CPU-derived thread count can drift between calls, producing
# a fresh cache key and reloading the ~2.3GB model into a CPU arena that never
# returns memory to the OS.
type ProviderCacheKey = tuple[
str,
str,
int | None,
bool | None,
int,
int,
str | None,
str | None,
str,
int | None,
int | None,
]
_EMBEDDING_PROVIDER_CACHE: dict[ProviderCacheKey, EmbeddingProvider] = {}
@@ -75,22 +86,26 @@ def _resolve_fastembed_runtime_knobs(
def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
"""Build a stable cache key from provider-relevant semantic embedding config.
"""Build a stable cache key from model-identity semantic embedding config.
Uses the *resolved* cache dir — not the raw config field — so different
FASTEMBED_CACHE_PATH values produce distinct cache keys even when the
config field itself is unset.
Deliberately excludes the FastEmbed thread/parallel knobs: they tune ONNX
execution, not which model weights are loaded, and resolving them from the
runtime CPU budget makes the key drift between calls in a container (#872).
"""
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
return (
app_config.semantic_embedding_provider.strip().lower(),
app_config.semantic_embedding_model,
app_config.semantic_embedding_dimensions,
app_config.semantic_embedding_forward_dimensions,
app_config.semantic_embedding_batch_size,
app_config.semantic_embedding_request_concurrency,
app_config.semantic_embedding_document_input_type,
app_config.semantic_embedding_query_input_type,
_resolve_cache_dir(app_config),
resolved_threads,
resolved_parallel,
)
@@ -103,10 +118,21 @@ def reset_embedding_provider_cache() -> None:
def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvider:
"""Create an embedding provider based on semantic config.
When semantic_embedding_dimensions is set in config, it overrides
the provider's default dimensions (384 for FastEmbed, 1536 for OpenAI).
When semantic_embedding_dimensions is set in config, it overrides the
provider's default dimensions (384 for FastEmbed, 1536 for OpenAI and
the LiteLLM OpenAI default). Custom LiteLLM models require an explicit
dimension because the vector table schema is created before the first
embedding response is available.
"""
cache_key = _provider_cache_key(app_config)
# Trigger: two threads miss the cache for the same key concurrently.
# Why: provider construction loads the ~2.3GB ONNX model and is slow, so we
# deliberately build it *outside* the lock to avoid serializing every caller
# behind a single cold start. This opens a by-design TOCTOU window where both
# threads may construct a provider.
# Outcome: the second check-and-set below resolves the race — the first writer
# wins and the loser's redundant provider is discarded, so the cache still
# yields a single process-wide singleton per key.
with _EMBEDDING_PROVIDER_CACHE_LOCK:
if cached_provider := _EMBEDDING_PROVIDER_CACHE.get(cache_key):
return cached_provider
@@ -151,11 +177,49 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide
request_concurrency=app_config.semantic_embedding_request_concurrency,
**extra_kwargs,
)
elif provider_name == "litellm":
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
model_name = app_config.semantic_embedding_model or "openai/text-embedding-3-small"
if model_name == "bge-small-en-v1.5":
model_name = "openai/text-embedding-3-small"
if (
app_config.semantic_embedding_dimensions is None
and model_name != "openai/text-embedding-3-small"
):
raise ValueError(
"semantic_embedding_dimensions must be set when "
"semantic_embedding_provider='litellm' uses a non-default model. "
f"Configured model: {model_name!r}."
)
provider = LiteLLMEmbeddingProvider(
model_name=model_name,
batch_size=app_config.semantic_embedding_batch_size,
request_concurrency=app_config.semantic_embedding_request_concurrency,
document_input_type=app_config.semantic_embedding_document_input_type,
query_input_type=app_config.semantic_embedding_query_input_type,
forward_dimensions=app_config.semantic_embedding_forward_dimensions,
**extra_kwargs,
)
else:
raise ValueError(f"Unsupported semantic embedding provider: {provider_name}")
with _EMBEDDING_PROVIDER_CACHE_LOCK:
if cached_provider := _EMBEDDING_PROVIDER_CACHE.get(cache_key):
return cached_provider
# Trigger: a distinct cache key is being inserted while the cache already
# holds entries for other keys.
# Why: the provider is meant to be a process-wide singleton (#872). A second
# key means something bypassed reuse — a real config change, or a regression
# that reintroduces volatile fields into the key — and each new key reloads
# the ~2.3GB ONNX model into a CPU arena that never releases memory.
# Outcome: surface the bypass so future leaks are diagnosable from logs.
if _EMBEDDING_PROVIDER_CACHE:
logger.warning(
"Creating a second distinct embedding provider in this process; "
"the model will be loaded again. existing_keys={existing} new_key={new}",
existing=list(_EMBEDDING_PROVIDER_CACHE.keys()),
new=cache_key,
)
_EMBEDDING_PROVIDER_CACHE[cache_key] = provider
return provider
@@ -178,21 +178,31 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Entity]:
async def find_by_ids_for_hydration(
self, ids: List[int], *, include_cross_project: bool = False
) -> Sequence[Entity]:
"""Fetch minimal entity fields needed for context hydration.
Context hydration only needs an entity's primary key, title, and external
UUID. Keeping this separate from find_by_ids avoids the relationship eager
loads that are useful for full entity reads but expensive for response shaping.
Args:
ids: Entity IDs to hydrate.
include_cross_project: Include IDs outside this repository's project scope.
Use only for IDs already reached through validated graph traversal.
"""
if not ids:
return []
query = (
self.select()
select(Entity)
.where(Entity.id.in_(ids))
.options(load_only(Entity.id, Entity.title, Entity.external_id))
)
if not include_cross_project:
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
+214 -27
View File
@@ -3,6 +3,9 @@
from __future__ import annotations
import asyncio
import math
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
@@ -14,6 +17,24 @@ if TYPE_CHECKING:
from fastembed import TextEmbedding # pragma: no cover
# Substrings that identify the ONNX "model artifact file is missing" load failure (as
# opposed to a config error, a download/network error, or a genuinely offline machine).
# An interrupted FastEmbed download can leave the HuggingFace snapshot dir present but
# missing ``model_optimized.onnx``; the ONNX runtime then raises ``NO_SUCHFILE`` and every
# subsequent load repeats it until the cache is cleared. Matched case-insensitively.
#
# IMPORTANT: this text match is necessary but NOT sufficient to trigger a purge. The error
# text alone cannot distinguish a corrupt cache from a normal cold load (model not yet
# downloaded). Purging is gated on a positive filesystem confirmation that the snapshot dir
# exists on disk but the model artifact file is missing — see ``_corrupt_model_subdirs``.
_MISSING_ARTIFACT_ERROR_MARKERS = (
"no_suchfile",
"model_optimized.onnx",
"file doesn't exist",
"no such file",
)
class FastEmbedEmbeddingProvider(EmbeddingProvider):
"""Local ONNX embedding provider backed by FastEmbed."""
@@ -52,6 +73,159 @@ class FastEmbedEmbeddingProvider(EmbeddingProvider):
self._model: TextEmbedding | None = None
self._model_lock = asyncio.Lock()
def _resolved_model_name(self) -> str:
"""Return the FastEmbed model name after applying our local aliases."""
return self._MODEL_ALIASES.get(self.model_name, self.model_name)
def _create_model(self) -> "TextEmbedding":
try:
from fastembed import TextEmbedding
except ImportError as exc: # pragma: no cover - exercised via tests with monkeypatch
raise SemanticDependenciesMissingError(
"fastembed package is missing. "
"Install/update basic-memory to include semantic dependencies: "
"pip install -U basic-memory"
) from exc
resolved_model_name = self._resolved_model_name()
# Constraint: onnxruntime's CPU memory arena grows to fit peak usage and never
# returns that memory to the OS. If a model is ever loaded more than once in a
# long-running process it leaks tens of GB (#872). FastEmbed exposes
# enable_cpu_mem_arena via its session-option kwargs, so we disable the arena to
# let any transient extra load free memory.
model_kwargs: dict = {
"model_name": resolved_model_name,
"enable_cpu_mem_arena": False,
}
if self.cache_dir is not None:
model_kwargs["cache_dir"] = self.cache_dir
if self.threads is not None:
model_kwargs["threads"] = self.threads
return TextEmbedding(**model_kwargs)
def _model_cache_candidates(self) -> list[tuple[Path, str]]:
"""Resolve ``(snapshot_dir, model_file)`` pairs for this model under ``cache_dir``.
FastEmbed stores each model under ``<cache_dir>/models--<org>--<repo>`` where the
repo is the model's HuggingFace source (e.g. ``BAAI/bge-small-en-v1.5`` resolves to
``models--qdrant--bge-small-en-v1.5-onnx-q``). We resolve the source and the expected
model artifact filename from FastEmbed's own model description so corruption detection
and deletion are scoped to exactly this model's tree — never the whole cache or
unrelated models.
Note: ``TextEmbedding._list_supported_models()`` is an intentional use of an
undocumented FastEmbed API. The broad ``except`` below is a known defensive fallback:
if the lookup ever changes shape we degrade to "no candidates" (so we never purge)
rather than crashing the load path.
"""
if self.cache_dir is None:
return []
# FastEmbed matches model names case-insensitively (model_management.py:
# ``model_name.lower() == model.model.lower()``). Mirror that here so a config like
# model="baai/bge-small-en-v1.5" still resolves to the same HF source/cache subdir.
resolved_model_name = self._resolved_model_name().lower()
candidates: list[tuple[Path, str]] = []
seen: set[Path] = set()
cache_root = Path(self.cache_dir)
try:
from fastembed import TextEmbedding
for description in TextEmbedding._list_supported_models():
if description.model.lower() != resolved_model_name:
continue
hf_source = description.sources.hf
model_file = description.model_file
if not hf_source or not model_file:
continue
# HuggingFace hub names cache dirs ``models--<repo with '/' -> '--'>``.
snapshot_dir = cache_root / f"models--{hf_source.replace('/', '--')}"
if snapshot_dir not in seen:
seen.add(snapshot_dir)
candidates.append((snapshot_dir, model_file))
except Exception as exc: # pragma: no cover - defensive: never block load on lookup
logger.warning(
"Could not resolve FastEmbed model source for cache cleanup: "
"model_name={model_name} error={error}",
model_name=resolved_model_name,
error=exc,
)
return candidates
def _corrupt_model_subdirs(self) -> list[Path]:
"""Return cache subdirs that are POSITIVELY confirmed corrupt by filesystem state.
A model is corrupt when its HuggingFace cache dir exists on disk but at least one
materialized snapshot revision is missing the expected model artifact file (e.g.
``model_optimized.onnx``) the exact fingerprint of an interrupted download. A normal
cold load (no cache dir yet) is NOT corruption and yields no entries here, so it can
never trigger a purge.
Inspection is PER-REVISION on purpose: HuggingFace keeps multiple revisions under one
``models--<repo>`` tree, so a corrupt current snapshot can coexist with an older
complete one. Checking ``rglob(model_file)`` across the whole tree would let the old
artifact mask the broken current revision and leave it self-perpetuating, so we
require every revision to carry the artifact.
"""
corrupt: list[Path] = []
for model_dir, model_file in self._model_cache_candidates():
# Trigger: the model's cache dir does not exist at all.
# Why: this is a normal cold/first load — the model simply hasn't been
# downloaded yet. Purging here would be wrong and pointless.
# Outcome: skip; not corrupt.
if not model_dir.exists():
continue
snapshots_root = model_dir / "snapshots"
revision_dirs = (
[d for d in snapshots_root.iterdir() if d.is_dir()]
if snapshots_root.is_dir()
else []
)
# Trigger: the cache dir exists but no snapshot revision has materialized.
# Why/Outcome: an interrupted download that never wrote a revision — corrupt.
if not revision_dirs:
corrupt.append(model_dir)
continue
# Trigger: any individual revision is missing the artifact (rglob covers the
# artifact at any depth within that revision, e.g. snapshots/<rev>/onnx/...).
# Why: a complete OLD revision must not mask a corrupt CURRENT one.
# Outcome: flag the model dir so the whole tree re-downloads cleanly.
if any(not any(rev.rglob(model_file)) for rev in revision_dirs):
corrupt.append(model_dir)
return corrupt
def _purge_model_subdirs(self, subdirs: list[Path]) -> bool:
"""Delete confirmed-corrupt cache subtrees so the next load re-downloads them.
Returns True when at least one targeted subdir is actually gone afterwards. On
Windows a locked file can make ``shutil.rmtree(ignore_errors=True)`` silently no-op;
reporting success in that case would let the caller retry against the same broken
cache, so each subdir only counts as removed once it has actually disappeared.
"""
removed_any = False
for subdir in subdirs:
logger.warning(
"Removing corrupt FastEmbed model cache to force re-download: {path}",
path=str(subdir),
)
shutil.rmtree(subdir, ignore_errors=True)
# Set removed only when the subdir is truly gone — a silent rmtree no-op
# (e.g. a locked file on Windows) must not be reported as a successful purge.
if not subdir.exists():
removed_any = True
return removed_any
@staticmethod
def _is_missing_artifact_error(exc: Exception) -> bool:
"""Return True when the load failure text matches the ONNX missing-artifact signature.
This is only the text-level gate; it is necessary but NOT sufficient to purge. The
purge additionally requires filesystem-confirmed corruption (``_corrupt_model_subdirs``)
so a transient/offline/"from any source" load error never deletes a valid cache.
"""
message = str(exc).lower()
return any(marker in message for marker in _MISSING_ARTIFACT_ERROR_MARKERS)
async def _load_model(self) -> "TextEmbedding":
if self._model is not None:
return self._model
@@ -60,36 +234,42 @@ class FastEmbedEmbeddingProvider(EmbeddingProvider):
if self._model is not None:
return self._model
def _create_model() -> "TextEmbedding":
try:
from fastembed import TextEmbedding
except (
ImportError
) as exc: # pragma: no cover - exercised via tests with monkeypatch
raise SemanticDependenciesMissingError(
"fastembed package is missing. "
"Install/update basic-memory to include semantic dependencies: "
"pip install -U basic-memory"
) from exc
resolved_model_name = self._MODEL_ALIASES.get(self.model_name, self.model_name)
if self.cache_dir is not None and self.threads is not None:
return TextEmbedding(
model_name=resolved_model_name,
cache_dir=self.cache_dir,
threads=self.threads,
)
if self.cache_dir is not None:
return TextEmbedding(model_name=resolved_model_name, cache_dir=self.cache_dir)
if self.threads is not None:
return TextEmbedding(model_name=resolved_model_name, threads=self.threads)
return TextEmbedding(model_name=resolved_model_name)
try:
self._model = await asyncio.to_thread(self._create_model)
except Exception as exc:
# Trigger: model construction raised the ONNX missing-artifact error AND a
# filesystem check positively confirms a corrupt cache subdir (the
# snapshot dir exists but the model artifact file is missing — the
# fingerprint of an interrupted download).
# Why: the raw ONNXRuntimeError is self-perpetuating — every retry hits the
# same broken snapshot until the cache is cleared. We must NOT misread a
# normal cold load (no snapshot dir, model simply not downloaded yet) or a
# transient/offline "from any source" error as corruption, because purging
# then breaks the happy path. Both the error-text gate and the positive
# filesystem confirmation are required before we delete anything.
# Outcome: confirmed corruption → purge exactly this model's subdir and retry
# once so a fresh download can land. Every other failure (including a
# retry that still fails) re-raises the ORIGINAL exception so the
# message stays actionable and we never loop.
if not self._is_missing_artifact_error(exc):
raise
corrupt_subdirs = self._corrupt_model_subdirs()
if not corrupt_subdirs:
raise
if not self._purge_model_subdirs(corrupt_subdirs):
raise
logger.info(
"Retrying FastEmbed model load after clearing corrupt cache: "
"model_name={model_name}",
model_name=self._resolved_model_name(),
)
self._model = await asyncio.to_thread(self._create_model)
self._model = await asyncio.to_thread(_create_model)
logger.info(
"FastEmbed model loaded: model_name={model_name} batch_size={batch_size} "
"threads={threads} configured_parallel={configured_parallel} "
"effective_parallel={effective_parallel}",
model_name=self._MODEL_ALIASES.get(self.model_name, self.model_name),
model_name=self._resolved_model_name(),
batch_size=self.batch_size,
threads=self.threads,
configured_parallel=self.parallel,
@@ -119,10 +299,17 @@ class FastEmbedEmbeddingProvider(EmbeddingProvider):
if effective_parallel is not None:
embed_kwargs["parallel"] = effective_parallel
vectors = list(model.embed(texts, **embed_kwargs))
# sqlite_search_repository.py uses a distance-to-similarity formula that assumes
# unit-normalized vectors (see the comment on line 65-67 of that file).
# Some models (e.g. multilingual ones) return vectors with norm > 1, so we
# L2-normalize here to satisfy that contract regardless of the chosen model.
normalized: list[list[float]] = []
for vector in vectors:
values = vector.tolist() if hasattr(vector, "tolist") else vector
normalized.append([float(value) for value in values])
values = vector.tolist() if hasattr(vector, "tolist") else list(vector)
norm = math.sqrt(sum(x * x for x in values))
if norm > 0:
values = [x / norm for x in values]
normalized.append([float(v) for v in values])
return normalized
vectors = await asyncio.to_thread(_embed_batch)
@@ -0,0 +1,230 @@
"""LiteLLM-based embedding provider for semantic indexing.
Routes embedding requests to 100+ providers (OpenAI, Anthropic, Google, Azure,
Bedrock, Cohere, etc.) via the litellm SDK. No proxy server needed.
Model strings use the ``provider/model`` format, e.g.
``openai/text-embedding-3-small``, ``cohere/embed-english-v3.0``,
``azure/my-embedding-deployment``.
See https://docs.litellm.ai/docs/embedding/supported_embedding for all
supported embedding models.
"""
from __future__ import annotations
import asyncio
import math
import os
from typing import Any
from basic_memory.repository.embedding_provider import EmbeddingProvider
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
def _default_input_types(model_name: str) -> tuple[str | None, str | None]:
"""Return role-specific LiteLLM input_type defaults for known asymmetric models."""
normalized = model_name.strip().lower()
# Cohere v3 embeddings require search_document/search_query to distinguish
# index-time passages from retrieval-time queries. LiteLLM supports both
# direct Cohere model names and provider-prefixed forms.
cohere_v3 = (
normalized.startswith("cohere/")
or normalized.startswith("bedrock/cohere.")
or normalized.startswith("cohere.")
or normalized.startswith("embed-")
) and "-v3" in normalized
if cohere_v3:
return "search_document", "search_query"
# NVIDIA retrieval embeddings use passage/query roles. The provider prefix
# is part of LiteLLM's model routing, so this stays narrowly scoped.
if normalized.startswith("nvidia_nim/"):
return "passage", "query"
return None, None
def _should_forward_dimensions(model_name: str, forward_dimensions: bool | None) -> bool:
"""Return whether configured dimensions should be sent to LiteLLM."""
if forward_dimensions is not None:
return forward_dimensions
normalized = model_name.strip().lower()
# Trigger: `dimensions` is both the Basic Memory vector schema size and a
# provider-side output-size request parameter in LiteLLM.
# Why: fixed-size models such as Cohere v3 still need the schema value for
# validation, but LiteLLM maps the request parameter to provider fields they
# reject. Only send it for model families that clearly support output-size
# control.
# Outcome: OpenAI/Azure text-embedding-3 reductions work, while fixed-size
# providers keep dimensions local to Basic Memory.
return "text-embedding-3" in normalized
def _import_litellm() -> Any:
"""Import LiteLLM without letting its import-time dotenv hook read cwd secrets."""
# Constraint: LiteLLM 1.85.0 loads .env files at import time when
# LITELLM_MODE defaults to DEV. Basic Memory intentionally does not load
# arbitrary cwd .env files, so set the production mode before importing
# unless the caller already made an explicit LiteLLM choice.
os.environ.setdefault("LITELLM_MODE", "PRODUCTION")
try:
import litellm
except ImportError as exc:
raise SemanticDependenciesMissingError(
"litellm dependency is missing. Install with: pip install litellm"
) from exc
return litellm
class LiteLLMEmbeddingProvider(EmbeddingProvider):
"""Embedding provider backed by the litellm SDK."""
def __init__(
self,
model_name: str = "openai/text-embedding-3-small",
*,
batch_size: int = 64,
request_concurrency: int = 4,
dimensions: int = 1536,
api_key: str | None = None,
timeout: float = 30.0,
document_input_type: str | None = None,
query_input_type: str | None = None,
forward_dimensions: bool | None = None,
) -> None:
self.model_name = model_name
self.dimensions = dimensions
self.batch_size = batch_size
self.request_concurrency = request_concurrency
self._api_key = api_key
self._timeout = timeout
default_document_input_type, default_query_input_type = _default_input_types(model_name)
self.document_input_type = document_input_type or default_document_input_type
self.query_input_type = query_input_type or default_query_input_type
self.forward_dimensions = forward_dimensions
def runtime_log_attrs(self) -> dict[str, Any]:
"""Return provider-specific runtime settings suitable for startup logs."""
attrs: dict[str, Any] = {
"provider_batch_size": self.batch_size,
"request_concurrency": self.request_concurrency,
}
if self.document_input_type:
attrs["document_input_type"] = self.document_input_type
if self.query_input_type:
attrs["query_input_type"] = self.query_input_type
if self.forward_dimensions is not None:
attrs["forward_dimensions"] = self.forward_dimensions
return attrs
def identity_key(self) -> str:
"""Return the embedding semantics that should invalidate stored vectors."""
document_input_type = self.document_input_type or "-"
query_input_type = self.query_input_type or "-"
forward_dimensions = str(
_should_forward_dimensions(self.model_name, self.forward_dimensions)
).lower()
return (
f"{self.model_name}:{self.dimensions}:"
f"document_input_type={document_input_type}:"
f"query_input_type={query_input_type}:"
f"forward_dimensions={forward_dimensions}"
)
async def _embed(self, texts: list[str], *, input_type: str | None) -> list[list[float]]:
if not texts:
return []
litellm = _import_litellm()
batches = [
texts[start : start + self.batch_size]
for start in range(0, len(texts), self.batch_size)
]
batch_vectors: list[list[list[float]] | None] = [None] * len(batches)
semaphore = asyncio.Semaphore(self.request_concurrency)
async def embed_batch(batch_index: int, batch: list[str]) -> None:
async with semaphore:
params: dict[str, Any] = {
"model": self.model_name,
"input": batch,
"drop_params": True,
"timeout": self._timeout,
}
if _should_forward_dimensions(self.model_name, self.forward_dimensions):
params["dimensions"] = self.dimensions
if self._api_key:
params["api_key"] = self._api_key
if input_type:
params["input_type"] = input_type
response = await litellm.aembedding(**params)
vectors_by_index: dict[int, list[float]] = {}
for item in response.data:
if isinstance(item, dict):
response_index = int(item["index"])
embedding = item["embedding"]
else:
response_index = int(item.index)
embedding = item.embedding
if response_index in vectors_by_index:
raise RuntimeError(
"LiteLLM embedding response returned duplicate vector indexes."
)
vectors_by_index[response_index] = [float(v) for v in embedding]
ordered_vectors: list[list[float]] = []
for index in range(len(batch)):
vector = vectors_by_index.get(index)
if vector is None:
raise RuntimeError(
"LiteLLM embedding response is missing expected vector index."
)
ordered_vectors.append(vector)
batch_vectors[batch_index] = ordered_vectors
await asyncio.gather(
*(embed_batch(batch_index, batch) for batch_index, batch in enumerate(batches))
)
all_vectors: list[list[float]] = []
for vectors in batch_vectors:
if vectors is None:
raise RuntimeError("LiteLLM embedding batch did not produce vectors.")
all_vectors.extend(vectors)
# sqlite_search_repository.py maps L2 distance to cosine similarity via
# `1 - L²/2`, which is correct only for unit-normalized vectors. LiteLLM
# routes to many backends (Cohere, Vertex, Bedrock, etc.); not all of
# them return normalized embeddings, so we normalize here to honor the
# provider contract regardless of the underlying model.
normalized: list[list[float]] = []
for vector in all_vectors:
norm = math.sqrt(sum(x * x for x in vector))
if norm > 0:
normalized.append([x / norm for x in vector])
else:
normalized.append(vector)
if normalized and len(normalized[0]) != self.dimensions:
raise RuntimeError(
f"Embedding model returned {len(normalized[0])}-dimensional vectors "
f"but provider was configured for {self.dimensions} dimensions."
)
return normalized
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return await self._embed(texts, input_type=self.document_input_type)
async def embed_query(self, text: str) -> list[float]:
vectors = await self._embed([text], input_type=self.query_input_type)
return vectors[0] if vectors else [0.0] * self.dimensions
@@ -705,6 +705,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str, str]:
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
@@ -762,14 +763,36 @@ class PostgresSearchRepository(SearchRepositoryBase):
type_placeholders.append(f":{param_name}")
conditions.append(f"search_index.type IN ({', '.join(type_placeholders)})")
# Handle note type filter using JSONB containment (parameterized)
# Handle observation category filter (parameterized for defense-in-depth).
# Trigger: caller passed `categories` to scope observation results.
# Why: `entity_types=["observation"]` only narrows to the observation row type;
# callers expect exact-category matching, not incidental text matches.
# Outcome: only rows whose indexed category exactly equals a requested value
# survive (entities/relations have NULL category and are excluded).
if categories:
category_placeholders = []
for idx, category in enumerate(categories):
param_name = f"category_{idx}"
params[param_name] = category
category_placeholders.append(f":{param_name}")
conditions.append(f"search_index.category IN ({', '.join(category_placeholders)})")
# Handle note type filter (frontmatter type field, parameterized).
# Trigger: caller passed `note_types` to scope by the frontmatter `type` field.
# Why: the stored note_type preserves the frontmatter casing (e.g. `Chapter`),
# but the filter is documented case-insensitive. JSONB `@>` containment is
# exact-match, so capitalized types were unfindable.
# Outcome: compare LOWER(metadata->>'note_type') against lowercased filter
# values so `note_types=["Chapter"]` matches a stored `Chapter`.
if note_types:
type_conditions = []
type_placeholders = []
for idx, note_type in enumerate(note_types):
param_name = f"note_type_{idx}"
params[param_name] = json.dumps({"note_type": note_type})
type_conditions.append(f"search_index.metadata @> CAST(:{param_name} AS jsonb)")
conditions.append(f"({' OR '.join(type_conditions)})")
params[param_name] = note_type.lower()
type_placeholders.append(f":{param_name}")
conditions.append(
f"LOWER(search_index.metadata->>'note_type') IN ({', '.join(type_placeholders)})"
)
# Handle date filter
if after_date:
@@ -879,6 +902,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -895,6 +919,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -919,6 +944,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
)
@@ -1008,6 +1034,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -1022,6 +1049,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -1041,6 +1069,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
)
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
@@ -5,7 +5,7 @@ from typing import Optional, Sequence, Union
from loguru import logger
from sqlalchemy import inspect as sa_inspect, select, text
from sqlalchemy import Executable, inspect as sa_inspect, select, text
from sqlalchemy.exc import NoResultFound, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -258,6 +258,28 @@ class ProjectRepository(Repository[Project]):
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
return True
async def scalar_vec_query(
self, query: Executable, params: Optional[dict] = None
) -> Optional[int]:
"""Run a scalar COUNT query that reads the sqlite-vec vec0 table.
Extension loading is per-connection, so the bare pooled session used by
`execute_query` cannot read vec0 virtual tables SQLite raises
"no such module: vec0". This helper loads sqlite-vec on the session it
opens before running the query, reusing the same loader as project delete.
Returns None when sqlite-vec cannot be loaded on this Python build, so
callers can fall back to the genuinely-missing-dependency path.
"""
async with db.scoped_session(self.session_maker) as session:
# Trigger: query reads the vec0-backed search_vector_embeddings table.
# Why: vec0 modules are only visible on a connection that loaded sqlite-vec.
# Outcome: load the extension here, or signal absence so the caller degrades.
if not await _load_sqlite_vec_on_session(session):
return None
result = await session.execute(query, params)
return result.scalar()
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.
@@ -13,6 +13,7 @@ from sqlalchemy import Result
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend
from basic_memory.repository.embedding_provider_factory import create_embedding_provider
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.search_repository_base import VectorSyncBatchResult
@@ -41,6 +42,7 @@ class SearchRepository(Protocol):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -59,6 +61,7 @@ class SearchRepository(Protocol):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -120,19 +123,37 @@ def create_search_repository(
Returns:
SearchRepository: Backend-appropriate search repository instance
"""
# Prefer explicit parameter; fall back to ConfigManager for backwards compatibility
# Resolve config once so backend detection and the shared embedding provider
# come from the same source. Prefer the explicit arg; fall back to ConfigManager
# for backwards compatibility.
config = app_config or ConfigManager().config
if database_backend is None:
config = app_config or ConfigManager().config
database_backend = config.database_backend
# Trigger: every request, sync batch, and project builds its own search repo.
# Why: each repo __init__ would otherwise call create_embedding_provider(), and
# the process-wide cache can be bypassed if its key ever drifts (#872), reloading
# the ~2.3GB ONNX model and leaking memory in onnxruntime's CPU arena.
# Outcome: resolve the cached singleton here once and inject it, so the provider
# is the single source of truth across all callers of this factory.
embedding_provider = None
if config.semantic_search_enabled:
embedding_provider = create_embedding_provider(config)
if database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
return PostgresSearchRepository( # pragma: no cover
session_maker,
project_id=project_id,
app_config=app_config,
embedding_provider=embedding_provider,
)
else:
return SQLiteSearchRepository(session_maker, project_id=project_id, app_config=app_config)
return SQLiteSearchRepository(
session_maker,
project_id=project_id,
app_config=app_config,
embedding_provider=embedding_provider,
)
__all__ = [
@@ -218,6 +218,7 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -234,6 +235,7 @@ class SearchRepositoryBase(ABC):
note_types: Filter by note types (from metadata.note_type)
after_date: Filter by created_at > after_date
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
categories: Filter observations by exact category (e.g. "requirement")
metadata_filters: Structured frontmatter metadata filters
limit: Maximum results to return
offset: Number of results to skip
@@ -256,6 +258,7 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -586,11 +589,19 @@ class SearchRepositoryBase(ABC):
def _embedding_model_key(self) -> str:
"""Build a stable model identity for vector invalidation checks."""
assert self._embedding_provider is not None
return (
f"{type(self._embedding_provider).__name__}:"
f"{self._embedding_provider.model_name}:"
f"{self._embedding_provider.dimensions}"
)
provider = self._embedding_provider
provider_identity = f"{provider.model_name}:{provider.dimensions}"
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
if isinstance(provider, LiteLLMEmbeddingProvider):
# Trigger: LiteLLM can change request semantics without changing model/dimensions.
# Why: asymmetric providers use role-specific document/query params, and
# dimension forwarding changes provider-side output-size behavior.
# Outcome: reindex treats those semantic config changes as stale vectors.
provider_identity = provider.identity_key()
return f"{type(provider).__name__}:{provider_identity}"
def _plan_entity_vector_shard(
self,
@@ -1777,6 +1788,7 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
categories: Optional[List[str]],
metadata_filters: Optional[dict],
retrieval_mode: SearchRetrievalMode,
min_similarity: Optional[float] = None,
@@ -1810,6 +1822,7 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=limit,
@@ -1829,6 +1842,7 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=limit,
@@ -1858,6 +1872,7 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
categories: Optional[List[str]],
metadata_filters: Optional[dict],
min_similarity: Optional[float] = None,
limit: int,
@@ -1968,6 +1983,7 @@ class SearchRepositoryBase(ABC):
note_types,
after_date,
search_item_types,
categories,
metadata_filters,
]
)
@@ -1981,6 +1997,7 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=SearchRetrievalMode.FTS,
limit=VECTOR_FILTER_SCAN_LIMIT,
@@ -2131,6 +2148,7 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
categories: Optional[List[str]],
metadata_filters: Optional[dict],
min_similarity: Optional[float] = None,
limit: int,
@@ -2155,6 +2173,7 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=SearchRetrievalMode.FTS,
limit=candidate_limit,
@@ -2170,6 +2189,7 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=candidate_limit,
@@ -733,6 +733,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str]:
"""Build SQLite FTS FROM/WHERE params shared by search and count."""
@@ -794,15 +795,36 @@ class SQLiteSearchRepository(SearchRepositoryBase):
type_placeholders.append(f":{param_name}")
conditions.append(f"search_index.type IN ({', '.join(type_placeholders)})")
# Handle note type filter (frontmatter type field, parameterized)
# Handle observation category filter (parameterized for defense-in-depth).
# Trigger: caller passed `categories` to scope observation results.
# Why: `entity_types=["observation"]` only narrows to the observation row type;
# callers expect exact-category matching, not incidental text matches.
# Outcome: only rows whose indexed category exactly equals a requested value
# survive (entities/relations have NULL category and are excluded).
if categories:
category_placeholders = []
for idx, category in enumerate(categories):
param_name = f"category_{idx}"
params[param_name] = category
category_placeholders.append(f":{param_name}")
conditions.append(f"search_index.category IN ({', '.join(category_placeholders)})")
# Handle note type filter (frontmatter type field, parameterized).
# Trigger: caller passed `note_types` to scope by the frontmatter `type` field.
# Why: the stored note_type preserves the frontmatter casing (e.g. `Chapter`),
# but the filter is documented case-insensitive; comparing raw values
# would miss capitalized types.
# Outcome: fold both sides to lowercase so `note_types=["Chapter"]` matches a
# stored `Chapter`, `chapter`, etc.
if note_types:
type_placeholders = []
for idx, t in enumerate(note_types):
param_name = f"note_type_{idx}"
params[param_name] = t
params[param_name] = t.lower()
type_placeholders.append(f":{param_name}")
conditions.append(
f"json_extract(search_index.metadata, '$.note_type') IN ({', '.join(type_placeholders)})"
"LOWER(json_extract(search_index.metadata, '$.note_type')) "
f"IN ({', '.join(type_placeholders)})"
)
# Handle date filter using datetime() for proper comparison
@@ -925,6 +947,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -941,6 +964,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -959,6 +983,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
)
@@ -1046,6 +1071,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
categories: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -1060,6 +1086,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -1073,6 +1100,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
categories=categories,
metadata_filters=metadata_filters,
)
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
+4
View File
@@ -42,6 +42,7 @@ class SearchQuery(BaseModel):
Optionally filter results by:
- note_types: Limit to specific note types (frontmatter "type")
- entity_types: Limit to search item types (entity/observation/relation)
- categories: Limit observation results to exact category matches (e.g. "requirement")
- after_date: Only items after date
- metadata_filters: Structured frontmatter filters (field -> value)
- tags: Convenience frontmatter tag filter
@@ -63,6 +64,7 @@ class SearchQuery(BaseModel):
# Optional filters
note_types: Optional[List[str]] = None # Filter by note type (frontmatter "type")
entity_types: Optional[List[SearchItemType]] = None # Filter by entity type
categories: Optional[List[str]] = None # Filter observations by exact category
after_date: Optional[Union[datetime, str]] = None # Time-based filter
metadata_filters: Optional[dict[str, Any]] = None # Structured frontmatter filters
tags: Optional[List[str]] = None # Convenience tag filter
@@ -85,6 +87,7 @@ class SearchQuery(BaseModel):
status_is_empty = self.status is None or (isinstance(self.status, str) and not self.status)
note_types_is_empty = not self.note_types
entity_types_is_empty = not self.entity_types
categories_is_empty = not self.categories
return (
self.permalink is None
and self.permalink_match is None
@@ -93,6 +96,7 @@ class SearchQuery(BaseModel):
and self.after_date is None
and note_types_is_empty
and entity_types_is_empty
and categories_is_empty
and metadata_is_empty
and tags_is_empty
and status_is_empty
+55 -19
View File
@@ -333,9 +333,14 @@ class ContextService:
relation_date_filter = ""
timeframe_condition = ""
# Add project filtering for security - ensure all entities and relations belong to the same project
project_filter = "AND e.project_id = :project_id"
relation_project_filter = "AND e_from.project_id = :project_id"
# Trigger: build_context starts from a project-scoped search result.
# Why: the seed entity must belong to the requested project, but an
# explicit relation edge may point at another project.
# Outcome: traversal follows only project-owned edges from reached
# entities, instead of forcing every reached entity into the seed project.
seed_project_filter = "AND e.project_id = :project_id"
connected_entity_project_filter = ""
relation_project_filter = "AND e_from.project_id = r.project_id"
# Use a CTE that operates directly on entity and relation tables
# This avoids the overhead of the search_index virtual table
@@ -351,7 +356,8 @@ class ContextService:
query = self._build_postgres_query(
entity_id_values,
date_filter,
project_filter,
seed_project_filter,
connected_entity_project_filter,
relation_date_filter,
relation_project_filter,
timeframe_condition,
@@ -362,7 +368,8 @@ class ContextService:
query = self._build_sqlite_query(
entity_id_values,
date_filter,
project_filter,
seed_project_filter,
connected_entity_project_filter,
relation_date_filter,
relation_project_filter,
timeframe_condition,
@@ -397,7 +404,8 @@ class ContextService:
self,
entity_id_values: str,
date_filter: str,
project_filter: str,
seed_project_filter: str,
connected_entity_project_filter: str,
relation_date_filter: str,
relation_project_filter: str,
timeframe_condition: str,
@@ -421,11 +429,13 @@ class ContextService:
0 as depth,
e.id as root_id,
e.created_at,
e.created_at as relation_date
e.created_at as relation_date,
e.project_id as project_id,
',' || e.id::text || ',' as entity_path
FROM entity e
WHERE e.id IN ({entity_id_values})
{date_filter}
{project_filter}
{seed_project_filter}
UNION ALL
@@ -477,15 +487,25 @@ class ContextService:
CASE
WHEN step_type = 1 THEN e_from.created_at
ELSE eg.relation_date
END as relation_date
END as relation_date,
CASE
WHEN step_type = 1 THEN eg.project_id
ELSE e.project_id
END as project_id,
CASE
WHEN step_type = 1 THEN eg.entity_path
ELSE eg.entity_path || e.id::text || ','
END as entity_path
FROM entity_graph eg
CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
JOIN relation r ON (
eg.type = 'entity' AND
(r.from_id = eg.id OR r.to_id = eg.id)
(r.from_id = eg.id OR r.to_id = eg.id) AND
r.project_id = eg.project_id
)
JOIN entity e_from ON (
r.from_id = e_from.id
{relation_date_filter}
{relation_project_filter}
)
LEFT JOIN entity e ON (
@@ -495,10 +515,17 @@ class ContextService:
ELSE r.from_id
END
{date_filter}
{project_filter}
{connected_entity_project_filter}
)
WHERE eg.depth < :max_depth
AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
AND (
step_type = 1 OR (
step_type = 2
AND e.id IS NOT NULL
AND e.id != eg.id
AND position(',' || e.id::text || ',' in eg.entity_path) = 0
)
)
{timeframe_condition}
)
-- Materialize and filter
@@ -529,7 +556,8 @@ class ContextService:
self,
entity_id_values: str,
date_filter: str,
project_filter: str,
seed_project_filter: str,
connected_entity_project_filter: str,
relation_date_filter: str,
relation_project_filter: str,
timeframe_condition: str,
@@ -555,11 +583,13 @@ class ContextService:
e.id as root_id,
e.created_at,
e.created_at as relation_date,
0 as is_incoming
0 as is_incoming,
e.project_id as project_id,
',' || e.id || ',' as entity_path
FROM entity e
WHERE e.id IN ({entity_id_values})
{date_filter}
{project_filter}
{seed_project_filter}
UNION ALL
@@ -580,11 +610,14 @@ class ContextService:
eg.root_id,
e_from.created_at,
e_from.created_at as relation_date,
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming,
eg.project_id as project_id,
eg.entity_path as entity_path
FROM entity_graph eg
JOIN relation r ON (
eg.type = 'entity' AND
(r.from_id = eg.id OR r.to_id = eg.id)
(r.from_id = eg.id OR r.to_id = eg.id) AND
r.project_id = eg.project_id
)
JOIN entity e_from ON (
r.from_id = e_from.id
@@ -615,7 +648,9 @@ class ContextService:
eg.root_id,
e.created_at,
eg.relation_date,
eg.is_incoming
eg.is_incoming,
e.project_id as project_id,
eg.entity_path || e.id || ',' as entity_path
FROM entity_graph eg
JOIN entity e ON (
eg.type = 'relation' AND
@@ -624,9 +659,10 @@ class ContextService:
ELSE eg.from_id
END
{date_filter}
{project_filter}
{connected_entity_project_filter}
)
WHERE eg.depth < :max_depth
AND instr(eg.entity_path, ',' || e.id || ',') = 0
{timeframe_condition}
)
SELECT DISTINCT
+22 -9
View File
@@ -1047,10 +1047,27 @@ class ProjectService:
f"WHERE c.project_id = :project_id {chunk_entity_exists}"
)
embeddings_result = await self.repository.execute_query(
embeddings_sql, {"project_id": project_id}
)
total_embeddings = embeddings_result.scalar() or 0
# The embeddings/orphan JOINs read search_vector_embeddings, a vec0
# virtual table. On SQLite that table is only visible on a connection
# that loaded sqlite-vec, so route these through scalar_vec_query which
# loads the extension first. Postgres has no per-connection extension
# and uses the bare pooled session.
async def _vec_scalar(vec_sql) -> int:
if is_postgres:
result = await self.repository.execute_query(
vec_sql, {"project_id": project_id}
)
return result.scalar() or 0
count = await self.repository.scalar_vec_query(vec_sql, {"project_id": project_id})
# Trigger: sqlite-vec genuinely can't load on this Python build.
# Why: without the extension the vec0 JOIN can't run at all.
# Outcome: raise the canonical error so the except block emits the
# true "sqlite-vec unavailable" fallback instead of reporting 0.
if count is None:
raise SAOperationalError(str(vec_sql), {}, Exception("no such module: vec0"))
return count
total_embeddings = await _vec_scalar(embeddings_sql)
# Orphaned chunks (chunks without embeddings — indicates interrupted indexing)
if is_postgres:
@@ -1065,11 +1082,7 @@ class ProjectService:
"LEFT JOIN search_vector_embeddings e ON e.rowid = c.id "
f"WHERE c.project_id = :project_id AND e.rowid IS NULL {chunk_entity_exists}"
)
orphan_result = await self.repository.execute_query(
orphan_sql, {"project_id": project_id}
)
orphaned_chunks = orphan_result.scalar() or 0
orphaned_chunks = await _vec_scalar(orphan_sql)
except SAOperationalError as exc:
# Trigger: sqlite_master can list vec0 virtual tables even when sqlite-vec
# is not loaded in the current Python runtime.
@@ -75,6 +75,7 @@ class _PreparedSearchQuery:
title: str | None
note_types: list[str] | None
search_item_types: list[SearchItemType] | None
categories: list[str] | None
after_date: datetime | None
metadata_filters: dict[str, Any] | None
retrieval_mode: SearchRetrievalMode
@@ -194,6 +195,7 @@ class SearchService:
title=query.title,
note_types=query.note_types,
search_item_types=query.entity_types,
categories=query.categories,
after_date=after_date,
metadata_filters=metadata_filters,
retrieval_mode=query.retrieval_mode or SearchRetrievalMode.FTS,
@@ -207,6 +209,7 @@ class SearchService:
or prepared.title
or prepared.note_types
or prepared.search_item_types
or prepared.categories
or prepared.after_date
or prepared.metadata_filters
)
@@ -221,6 +224,7 @@ class SearchService:
prepared.metadata_filters
or prepared.note_types
or prepared.search_item_types
or prepared.categories
or prepared.after_date
)
@@ -239,6 +243,7 @@ class SearchService:
title=prepared.title,
note_types=prepared.note_types,
search_item_types=prepared.search_item_types,
categories=prepared.categories,
after_date=prepared.after_date,
metadata_filters=prepared.metadata_filters,
retrieval_mode=prepared.retrieval_mode,
@@ -260,6 +265,7 @@ class SearchService:
title=prepared.title,
note_types=prepared.note_types,
search_item_types=prepared.search_item_types,
categories=prepared.categories,
after_date=prepared.after_date,
metadata_filters=prepared.metadata_filters,
retrieval_mode=prepared.retrieval_mode,
+13 -2
View File
@@ -1447,7 +1447,16 @@ class SyncService:
f"to_name={relation.to_name}"
)
resolved_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
# Use strict=True: deferred resolution should only fill in to_id when an
# exact permalink/title/file_path match exists. The fuzzy fallback (search-based
# token match) would silently resolve ambiguous links like
# `[[overview (state-management/session-execution)]]` to whichever entity shares
# the most tokens, polluting the graph with confidently-wrong edges that no
# audit catches. Leaving such relations unresolved keeps to_id=NULL so they
# surface as forward references and can be fixed by the producer.
resolved_entity = await self.entity_service.link_resolver.resolve_link(
relation.to_name, strict=True
)
# ignore reference to self
if resolved_entity and resolved_entity.id != relation.from_id:
@@ -1735,7 +1744,9 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
entity_repository = EntityRepository(session_maker, project_id=project.id)
observation_repository = ObservationRepository(session_maker, project_id=project.id)
relation_repository = RelationRepository(session_maker, project_id=project.id)
search_repository = create_search_repository(session_maker, project_id=project.id)
search_repository = create_search_repository(
session_maker, project_id=project.id, app_config=app_config
)
project_repository = ProjectRepository(session_maker)
# Initialize services
+14 -2
View File
@@ -526,8 +526,20 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
# Process list of tags
if isinstance(tags, list):
# First strip whitespace, then strip leading '#' characters to prevent accumulation
return [tag.strip().lstrip("#") for tag in tags if tag and tag.strip()]
# Trigger: a list element may itself be a comma-separated string (e.g. typer collects
# `--tags "a,b"` into the one-element list `["a,b"]`).
# Why: keep the CLI list path and the MCP bare-string path on a single source of truth so
# `--tags "a,b"`, `--tags a --tags b`, and `tags="a,b"` all converge to the same tags.
# Outcome: flatten by splitting each element on commas before stripping '#' / whitespace.
# Skip None entries (e.g. a YAML `tags: [alpha, null]`) so they are not revived as
# the literal tag "None" by str(raw); the old list branch ignored such falsy entries.
return [
tag.strip().lstrip("#")
for raw in tags
if raw is not None
for tag in str(raw).split(",")
if tag and tag.strip()
]
# Process string input
if isinstance(tags, str):
View File
@@ -0,0 +1,50 @@
"""Bug hunt regression test (#6): `bm tool read-note` exit code on a
path-traversal SECURITY_VALIDATION_ERROR.
The MCP read_note tool detects path-traversal identifiers and returns
{"error": "SECURITY_VALIDATION_ERROR", ...}. Every other wrapped tool command
exits non-zero on an error payload; read-note used to print the payload and
exit 0. These integration tests assert read-note now matches its siblings.
"""
import pytest
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
from basic_memory.mcp.tools import read_note as mcp_read_note
runner = CliRunner()
TRAVERSAL_IDENTIFIER = "../../../../etc/passwd"
@pytest.mark.asyncio
async def test_read_note_security_error_mcp_emits_error_field(
app, app_config, test_project, config_manager
):
"""MCP read_note JSON flags the path traversal with a SECURITY_VALIDATION_ERROR."""
result = await mcp_read_note(
identifier=TRAVERSAL_IDENTIFIER,
project=test_project.name,
output_format="json",
)
assert isinstance(result, dict)
assert result.get("error") == "SECURITY_VALIDATION_ERROR"
def test_read_note_security_error_cli_exit_code_matches_other_tools(
app, app_config, test_project, config_manager
):
"""CLI read-note must not exit 0 when the MCP payload carries an error."""
result = runner.invoke(
cli_app,
["tool", "read-note", TRAVERSAL_IDENTIFIER, "--project", test_project.name],
)
combined = result.stdout
assert "SECURITY_VALIDATION_ERROR" in combined, combined
assert result.exit_code != 0, (
f"read-note exited {result.exit_code} on a SECURITY_VALIDATION_ERROR; "
"other tool commands exit non-zero on error payloads"
)
@@ -0,0 +1,76 @@
"""Bug hunt regression test (#3): `bm tool recent-activity` page_size default.
The MCP recent_activity tool defaults page_size=10; the CLI wrapper used to
default to 50. Because page_size becomes the SQL LIMIT for the query, identical
default invocations returned a different number of rows from CLI vs MCP. This
integration test proves the CLI default now matches the MCP default of 10.
"""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
MCP_DEFAULT_PAGE_SIZE = 10
def _write_note(title: str, folder: str, content: str) -> None:
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
title,
"--folder",
folder,
"--content",
content,
],
)
assert result.exit_code == 0, result.output
def test_recent_activity_default_page_size_matches_mcp(
app, app_config, test_project, config_manager, monkeypatch
):
"""CLI recent-activity default page_size must match the MCP tool default (10)."""
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
for i in range(15):
_write_note(
f"Parity Note {i:02d}",
"parity-recent",
f"# Parity Note {i:02d}\n\nUnique body token PARITY{i:02d}.",
)
mcp_default_result = runner.invoke(
cli_app,
[
"tool",
"recent-activity",
"--project",
test_project.name,
"--page-size",
str(MCP_DEFAULT_PAGE_SIZE),
],
)
assert mcp_default_result.exit_code == 0, mcp_default_result.output
mcp_default_rows = json.loads(mcp_default_result.stdout)
assert len(mcp_default_rows) == MCP_DEFAULT_PAGE_SIZE
cli_default_result = runner.invoke(
cli_app,
["tool", "recent-activity", "--project", test_project.name],
)
assert cli_default_result.exit_code == 0, cli_default_result.output
cli_default_rows = json.loads(cli_default_result.stdout)
assert len(cli_default_rows) == MCP_DEFAULT_PAGE_SIZE, (
f"CLI recent-activity default returned {len(cli_default_rows)} rows but "
f"the MCP tool default (page_size={MCP_DEFAULT_PAGE_SIZE}) returns "
f"{len(mcp_default_rows)}; the CLI and MCP default page_size must match."
)
@@ -0,0 +1,73 @@
"""Bug hunt regression test (#4): `bm tool search-notes` --category filter.
The MCP search_notes tool exposes a `categories` parameter for exact-match
observation-category filtering. The CLI wrapper had no equivalent flag. This
integration test asserts the CLI now exposes `--category` and that it filters
observation results to the requested category exactly.
"""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def _write_note(title: str, folder: str, content: str) -> dict:
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
title,
"--folder",
folder,
"--content",
content,
],
)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def test_search_notes_exposes_category_filter(app, app_config, test_project, config_manager):
"""CLI search-notes should expose --category like the MCP `categories` param."""
_write_note(
"Category Filter Note",
"parity-category",
"# Category Filter Note\n\n"
"## Observations\n"
"- [requirement] system must authenticate users CATTOKEN\n"
"- [decision] use OAuth for auth CATTOKEN\n",
)
result = runner.invoke(
cli_app,
[
"tool",
"search-notes",
"CATTOKEN",
"--project",
test_project.name,
"--entity-type",
"observation",
"--category",
"requirement",
],
)
assert result.exit_code == 0, (
"`--category` filter is not supported by the CLI search-notes command "
"even though the MCP search_notes tool documents a `categories` param. "
f"exit_code={result.exit_code} output={result.output}"
)
payload = json.loads(result.stdout)
categories = {r.get("category") for r in payload.get("results", []) if r.get("category")}
assert categories == {"requirement"}, (
"--category requirement should return only requirement observations, "
f"got categories={categories}"
)
@@ -0,0 +1,193 @@
"""Bug hunt regression tests: `bm tool write-note` CLI/MCP parity.
Covers three confirmed bugs found by the integration-test bug hunt:
- #1 / #5: write-note exits 0 on a conflict/error JSON result (silent failure,
inconsistent with delete-note/edit-note/search-notes which exit non-zero).
- #2: write-note had no `--overwrite` flag even though the MCP write_note tool
supports overwrite=True to replace an existing note.
These are integration tests: real CliRunner -> CLI command -> MCP tool ->
in-process ASGI API -> real SQLite/Postgres DB and filesystem. No mocks.
"""
import asyncio
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
from basic_memory.mcp.tools import write_note as mcp_write_note
runner = CliRunner()
# --- #1: write-note exits non-zero on a conflict/error result ---
def _write_conflict(content_token: str):
return runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Conflict Exit Note",
"--folder",
"parity-conflict",
"--content",
f"# Conflict Exit Note\n\n{content_token}",
"--project",
"test-project",
],
)
def test_write_note_nonzero_exit_on_conflict_error(app, app_config, test_project, config_manager):
"""write-note should exit non-zero when the MCP result carries an error."""
first = _write_conflict("FIRST")
assert first.exit_code == 0, first.output
second = _write_conflict("SECOND")
payload = json.loads(second.stdout)
# Confirm the MCP layer reported a conflict error in the JSON.
assert payload.get("error") == "NOTE_ALREADY_EXISTS", payload
assert payload.get("action") == "conflict", payload
# Parity with delete-note / edit-note / search-notes: an error result
# must drive a non-zero exit code so scripts can detect failure.
assert second.exit_code != 0, (
"write-note returned an error JSON payload "
f"({payload.get('error')}) but exited 0. Sibling tool commands "
"(delete-note, edit-note, search-notes) exit non-zero on error."
)
# --- #5: blocked NOTE_ALREADY_EXISTS write must not report success ---
def _cli_write(project_name: str):
return runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Conflict Note",
"--folder",
"conflict",
"--content",
"# Conflict Note\n\nFirst body.\n",
"--project",
project_name,
],
)
def test_mcp_write_note_conflict_emits_error(app, app_config, test_project, config_manager):
"""Baseline: the MCP tool reports NOTE_ALREADY_EXISTS on a blocked re-write."""
async def _go():
first = await mcp_write_note(
title="Conflict Note",
content="# Conflict Note\n\nFirst body.\n",
directory="conflict",
project=test_project.name,
output_format="json",
)
# output_format="json" returns a dict; narrow for the type checker.
assert isinstance(first, dict)
assert first.get("action") == "created"
assert "error" not in first
second = await mcp_write_note(
title="Conflict Note",
content="# Conflict Note\n\nSecond body (should be blocked).\n",
directory="conflict",
project=test_project.name,
output_format="json",
)
return second
second = asyncio.run(_go())
assert isinstance(second, dict)
assert second.get("error") == "NOTE_ALREADY_EXISTS"
assert second.get("action") == "conflict"
assert second.get("file_path") is None
def test_cli_write_note_conflict_should_exit_nonzero(app, app_config, test_project, config_manager):
"""CLI write-note must NOT exit 0 when the write was blocked by a conflict."""
first = _cli_write(test_project.name)
assert first.exit_code == 0, first.output
first_payload = json.loads(first.stdout)
assert first_payload["action"] == "created"
second = _cli_write(test_project.name)
assert "NOTE_ALREADY_EXISTS" in second.stdout, second.output
assert second.exit_code != 0, (
f"write-note exited {second.exit_code} after a blocked NOTE_ALREADY_EXISTS "
"write; the note was NOT written but the CLI reported success"
)
# --- #2: write-note --overwrite flag (MCP overwrite=True parity) ---
def _write_overwrite(args_extra: list[str]):
return runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Overwrite Parity Note",
"--folder",
"parity-overwrite",
"--content",
"# Overwrite Parity Note\n\nVERSION_BODY",
*args_extra,
],
)
def test_write_note_cli_can_overwrite_like_mcp(app, app_config, test_project, config_manager):
"""CLI write-note must be able to overwrite an existing note (MCP overwrite=True)."""
first = _write_overwrite(["--project", test_project.name])
assert first.exit_code == 0, first.output
first_data = json.loads(first.stdout)
permalink = first_data["permalink"]
second = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Overwrite Parity Note",
"--folder",
"parity-overwrite",
"--content",
"# Overwrite Parity Note\n\nNEW_VERSION_BODY",
"--project",
test_project.name,
"--overwrite",
],
)
assert second.exit_code == 0, (
"CLI write-note has no way to overwrite an existing note even though "
"the MCP write_note tool supports overwrite=True. "
f"exit_code={second.exit_code} output={second.output}"
)
read = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--project", test_project.name],
)
assert read.exit_code == 0, read.output
read_data = json.loads(read.stdout)
assert "NEW_VERSION_BODY" in (read_data.get("content") or "")
@@ -0,0 +1,219 @@
"""Regression tests for move_note edge cases found by the integration bug hunt.
Bug #11: move_note did not resolve memory:// URL identifiers, even though its
docstring advertises them and read_note/edit_note/delete_note all accept them.
Bug #12: move_note's structural "<seg>/projects/<seg>/file.md" heuristic wrongly
rejected legitimate same-project nested moves (e.g. "notes/projects/2025/file.md")
as cross-project moves. The heuristic was removed; cross-project detection now relies
on the leading segment matching a known project name plus the post-move outcome
backstop.
These are integration tests: real MCP server -> FastAPI -> database -> filesystem.
"""
import pytest
from fastmcp import Client
def _result(r):
return r.structured_content["result"]
# --- Bug #11: memory:// URL resolution ---
@pytest.mark.asyncio
async def test_move_note_accepts_memory_url(mcp_server, app, test_project):
"""move_note should accept a memory:// URL identifier like read/edit/delete do."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Memory Url Move",
"directory": "src",
"content": "# Memory Url Move\n\nbody",
"output_format": "json",
},
)
move = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "memory://src/memory-url-move",
"destination_path": "dst/memory-url-move.md",
"output_format": "json",
},
)
result = _result(move)
assert result["moved"] is True, (
f"move_note should resolve memory:// URLs but failed: {result.get('error')}"
)
assert result["file_path"] == "dst/memory-url-move.md"
@pytest.mark.asyncio
async def test_move_note_bare_permalink_works_control(mcp_server, app, test_project):
"""Control: bare permalink (no memory://) DOES work for move_note."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Bare Permalink Move",
"directory": "src",
"content": "# Bare Permalink Move\n\nbody",
"output_format": "json",
},
)
move = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "src/bare-permalink-move",
"destination_path": "dst/bare-permalink-move.md",
"output_format": "json",
},
)
result = _result(move)
assert result["moved"] is True, result.get("error")
@pytest.mark.asyncio
async def test_delete_note_accepts_memory_url_control(mcp_server, app, test_project):
"""Control: delete_note DOES resolve memory:// URLs (proves the contract)."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Delete Memory Url",
"directory": "src",
"content": "# Delete Memory Url\n\nbody",
"output_format": "json",
},
)
d = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "memory://src/delete-memory-url",
"output_format": "json",
},
)
assert _result(d)["deleted"] is True
@pytest.mark.asyncio
async def test_edit_note_accepts_memory_url_control(mcp_server, app, test_project):
"""Control: edit_note DOES resolve memory:// URLs (proves the contract)."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Edit Memory Url",
"directory": "src",
"content": "# Edit Memory Url\n\nbody",
"output_format": "json",
},
)
e = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "memory://src/edit-memory-url",
"operation": "append",
"content": "\nEDITED",
"output_format": "json",
},
)
ec = _result(e)
assert ec.get("error") is None
assert ec.get("fileCreated") is False
# --- Bug #12: nested 'projects' folder false positive ---
@pytest.mark.asyncio
async def test_move_into_nested_projects_folder_not_flagged(mcp_server, app, test_project):
"""A legit nested folder like notes/projects/2025/note.md must NOT be flagged
as a cross-project move (the structural 'projects'-segment heuristic was removed)."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Nested Projects Note",
"directory": "inbox",
"content": "# Nested\n\nbody",
"output_format": "json",
},
)
move = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "inbox/nested-projects-note",
"destination_path": "notes/projects/2025/nested-projects-note.md",
"output_format": "json",
},
)
result = _result(move)
assert result.get("error") != "CROSS_PROJECT_MOVE_NOT_SUPPORTED", (
"Legit nested 'projects' folder wrongly flagged as cross-project move"
)
assert result["moved"] is True, result.get("error")
@pytest.mark.asyncio
async def test_move_outcome_mismatch_guidance_uses_actual_landing_path(
mcp_server, app, test_project, monkeypatch
):
"""The text guidance should point agents at the actual post-move path.
The real move service stores the requested destination verbatim today, so the
mismatch backstop is exercised by returning a divergent file_path after the real
move completes.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Outcome Guidance Note",
"directory": "source",
"content": "# Outcome Guidance Note\n\nbody",
"output_format": "json",
},
)
from basic_memory.mcp.clients import KnowledgeClient
real_move_entity = KnowledgeClient.move_entity
actual_landing_path = "unexpected/outcome-guidance-note.md"
async def diverging_move_entity(self, entity_id, destination_path):
result = await real_move_entity(self, entity_id, destination_path)
return result.model_copy(update={"file_path": actual_landing_path})
monkeypatch.setattr(KnowledgeClient, "move_entity", diverging_move_entity)
move = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "source/outcome-guidance-note",
"destination_path": "target/outcome-guidance-note.md",
},
)
guidance = move.content[0].text
stale_identifier = "source/outcome-guidance-note"
assert "Unexpected Result Location" in guidance
assert f'read_note("{actual_landing_path}")' in guidance
assert f'delete_note("{actual_landing_path}", project="{test_project.name}")' in guidance
assert f'read_note("{stale_identifier}")' not in guidance
assert f'delete_note("{stale_identifier}", project="{test_project.name}")' not in guidance
@@ -0,0 +1,241 @@
"""Bughunt regression tests: pagination + project-name display in navigation tools.
These integration tests come from the integration-test bug hunt. They exercise the
real MCP server (FastMCP Client), real DB, and real ASGI routing no mocks.
Covered bugs:
- #9 search_notes accepted non-positive page_size and returned a misleading
has_more=true with zero rows (inconsistent with recent_activity validation).
- #10 build_context with page_size<=0 silently dropped the requested primary entity
(primary_count=0 for a valid memory:// URL).
- #13 recent_activity text output printed the raw project UUID instead of the
project name when routed via project_id.
"""
import json
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def _parse(result):
return json.loads(result.content[0].text)
async def _seed_notes(client, project, n=15):
for i in range(n):
await client.call_tool(
"write_note",
{
"project": project,
"title": f"Pg Note {i + 1:02d}",
"directory": "pg",
"content": f"# Pg Note {i + 1:02d}\n\npagination probe content.",
"tags": "pg,probe",
},
)
# --- Bug #9: search_notes pagination validation ---
@pytest.mark.asyncio
async def test_search_notes_rejects_nonpositive_page_size(mcp_server, app, test_project):
"""search_notes must reject non-positive page_size like recent_activity does,
instead of returning a misleading has_more=true with zero rows."""
async with Client(mcp_server) as client:
await _seed_notes(client, test_project.name, 15)
# Baseline: recent_activity correctly rejects page_size < 1.
with pytest.raises(ToolError, match="page_size"):
await client.call_tool(
"recent_activity",
{"project": test_project.name, "page_size": -3, "output_format": "json"},
)
# search_notes must now reject page_size=0 with the same guard, rather than
# returning an empty payload that misleadingly claims more results exist.
with pytest.raises(ToolError, match="page_size"):
await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "pagination",
"search_type": "text",
"page": 1,
"page_size": 0,
"output_format": "json",
},
)
# Negative page_size must not return an arbitrary uncapped slice — also rejected.
with pytest.raises(ToolError, match="page_size"):
await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "pagination",
"search_type": "text",
"page": 1,
"page_size": -3,
"output_format": "json",
},
)
# page < 1 is rejected too.
with pytest.raises(ToolError, match="page"):
await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "pagination",
"search_type": "text",
"page": 0,
"page_size": 5,
"output_format": "json",
},
)
# A valid page_size still works and reports honest pagination.
ok = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "pagination",
"search_type": "text",
"page": 1,
"page_size": 5,
"output_format": "json",
},
)
d_ok = _parse(ok)
assert len(d_ok["results"]) == 5
assert d_ok["has_more"] is True
# --- Bug #9 (CLI surface): search-notes --page-size 0 ---
def _write_cli(title, folder="pgcli"):
return runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
title,
"--folder",
folder,
"--content",
f"# {title}\n\nclipagination probe body.",
],
)
def test_cli_search_notes_page_size_zero(app, app_config, test_project, config_manager):
"""`bm tool search-notes ... --page-size 0` must fail fast instead of returning a
misleading has_more=true with zero rows."""
for i in range(5):
w = _write_cli(f"CliPg Note {i:02d}")
assert w.exit_code == 0, w.output
res = runner.invoke(
cli_app,
["tool", "search-notes", "clipagination", "--local", "--page-size", "0"],
)
# The fix raises ValueError, which the CLI maps to a non-zero exit with a clear
# message — the faithful "no misleading pagination signal" outcome at the CLI.
assert res.exit_code != 0, res.output
assert "page_size" in res.output
# --- Bug #10: build_context must always return the requested primary entity ---
@pytest.mark.asyncio
async def test_build_context_nonpositive_page_size_drops_primary(mcp_server, app, test_project):
"""build_context with a valid URL must return its primary entity (or raise) —
a non-positive page_size must never silently drop it."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Primary Note",
"directory": "ctx",
"content": "# Primary Note\n\n## Relations\n- relates_to [[Other Note]]\n",
"tags": "ctx",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Other Note",
"directory": "ctx",
"content": "# Other Note\n\nrelated content.\n",
"tags": "ctx",
},
)
# Sanity: a normal page_size returns the primary note.
r_ok = await client.call_tool(
"build_context",
{"project": test_project.name, "url": "ctx/primary-note", "output_format": "json"},
)
d_ok = _parse(r_ok)
assert d_ok["metadata"]["primary_count"] == 1
# page_size=0 must NOT silently drop the requested entity. The fix rejects it
# with a clear error rather than returning primary_count=0.
with pytest.raises(ToolError, match="page_size"):
await client.call_tool(
"build_context",
{
"project": test_project.name,
"url": "ctx/primary-note",
"page_size": 0,
"output_format": "json",
},
)
# --- Bug #13: recent_activity header shows project name, not raw UUID ---
@pytest.mark.asyncio
async def test_recent_activity_project_id_header_shows_name_not_uuid(
mcp_server, app, test_project, config_manager
):
"""When routed via project_id, the text header must name the project, not the UUID."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "RA Header Note",
"directory": "ra",
"content": "# RA Header Note\n\nToken",
"output_format": "json",
},
)
result = await client.call_tool(
"recent_activity",
{"project_id": test_project.external_id, "output_format": "text"},
)
text = result.content[0].text
# The human-readable header should reference the project NAME, not the UUID.
assert test_project.external_id not in text, (
f"recent_activity header leaked the raw external_id UUID:\n{text[:300]}"
)
assert test_project.name in text, (
f"recent_activity header should contain the project name:\n{text[:300]}"
)
@@ -0,0 +1,81 @@
"""Bug: CLI `write-note --tags "a,b"` does NOT split the comma string, but the
MCP write_note(tags="a,b") DOES (parse_tags splits a bare string but treats each
list element as a single literal tag).
Typer collects a single --tags value into a one-element list ['a,b'], and
parse_tags(['a,b']) returns ['a,b'] (no per-element comma split). The MCP tool
receives the bare string 'a,b' and parse_tags('a,b') returns ['a','b'].
Result: the SAME comma-string input yields different tags on CLI vs MCP, even
though write_note's docstring promises comma-separated-string support.
"""
import json
import pytest
from fastmcp import Client
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def test_cli_write_note_comma_tags_split_matches_mcp(app, app_config, test_project, config_manager):
# CLI: single --tags value containing a comma
write = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"CLI Comma Split",
"--folder",
"cli-comma-split",
"--content",
"# CLI Comma Split\n\nbody",
"--tags",
"alpha,beta",
],
)
assert write.exit_code == 0, write.output
permalink = json.loads(write.stdout)["permalink"]
read = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--include-frontmatter", "--local"],
)
assert read.exit_code == 0, read.output
content = json.loads(read.stdout)["content"]
# Correct behavior: two distinct tags (matching MCP write_note semantics).
# splitlines() is line-ending agnostic (Windows CRLF vs POSIX LF).
content_lines = content.splitlines()
assert "- alpha" in content_lines and "- beta" in content_lines, (
"CLI --tags 'alpha,beta' should split into two tags like MCP write_note does; "
f"got frontmatter:\n{content}"
)
assert "alpha,beta" not in content, "comma string must not survive as a single literal tag"
@pytest.mark.asyncio
async def test_mcp_write_note_comma_tags_split_baseline(mcp_server, app, test_project):
"""Baseline: MCP write_note DOES split comma strings (the behavior CLI should match)."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "MCP Comma Split",
"directory": "mcp-comma-split",
"content": "# MCP Comma Split\n\nbody",
"tags": "alpha,beta",
},
)
read = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "MCP Comma Split"},
)
text = read.content[0].text
text_lines = text.splitlines()
assert "- alpha" in text_lines and "- beta" in text_lines, (
f"MCP write_note should split comma string into two tags; got:\n{text}"
)
@@ -0,0 +1,277 @@
"""Integration tests for `basic-memory tool delete-note`."""
import json
from pathlib import Path
from typing import Any
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def _write_note(
title: str,
folder: str,
content: str,
*,
project: str | None = None,
) -> dict[str, Any]:
args = [
"tool",
"write-note",
"--title",
title,
"--folder",
folder,
"--content",
content,
]
if project is not None:
args.extend(["--project", project])
result = runner.invoke(cli_app, args)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def _read_note(identifier: str, *, project: str | None = None) -> dict[str, Any]:
args = ["tool", "read-note", identifier]
if project is not None:
args.extend(["--project", project])
result = runner.invoke(cli_app, args)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def _delete_note(
identifier: str,
*,
is_directory: bool = False,
project: str | None = None,
project_id: str | None = None,
local: bool = False,
) -> tuple[int, dict[str, Any], str]:
args = ["tool", "delete-note", identifier]
if is_directory:
args.append("--is-directory")
if project is not None:
args.extend(["--project", project])
if project_id is not None:
args.extend(["--project-id", project_id])
if local:
args.append("--local")
result = runner.invoke(cli_app, args)
payload = json.loads(result.stdout) if result.stdout else {}
return result.exit_code, payload, result.output
def _search_notes(
query: str,
*,
mode_flag: str | None = None,
page_size: int = 20,
) -> dict[str, Any]:
args = ["tool", "search-notes", query, "--page-size", str(page_size)]
if mode_flag is not None:
args.append(mode_flag)
result = runner.invoke(
cli_app,
args,
)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def _project_file(test_project, file_path: str) -> Path:
return Path(test_project.path) / file_path
def test_delete_note_removes_file_database_record_and_search_result(
app, app_config, test_project, config_manager
) -> None:
"""Single-note deletion removes the note from every user-visible surface."""
note = _write_note(
"CLI Delete Single Note",
"delete-cli",
"# CLI Delete Single Note\n\nUniqueSingleDeleteToken\n\n- [status] ready to delete",
)
note_path = _project_file(test_project, note["file_path"])
assert note_path.exists()
exit_code, payload, output = _delete_note(note["permalink"])
assert exit_code == 0, output
assert payload == {
"deleted": True,
"title": "CLI Delete Single Note",
"permalink": note["permalink"],
"file_path": note["file_path"],
}
assert not note_path.exists()
missing = _read_note(note["permalink"])
assert missing["title"] is None
assert missing["permalink"] is None
assert missing["content"] is None
search = _search_notes("CLI Delete Single Note", mode_flag="--title")
assert search["total"] == 0
assert search["results"] == []
def test_delete_note_not_found_returns_json_without_error(
app, app_config, test_project, config_manager
) -> None:
"""A missing note is machine-readable and does not produce a CLI failure."""
exit_code, payload, output = _delete_note("delete-cli/missing-note")
assert exit_code == 0, output
assert payload == {
"deleted": False,
"title": None,
"permalink": None,
"file_path": None,
}
def test_delete_note_case_mismatch_does_not_delete_exact_note(
app, app_config, test_project, config_manager
) -> None:
"""Strict CLI deletes must not fuzzy-match a differently cased title."""
note = _write_note(
"CLI CamelCase Delete Note",
"delete-cli",
"# CLI CamelCase Delete Note\n\nCaseSensitiveDeleteToken",
)
exit_code, payload, output = _delete_note("cli camelcase delete note")
assert exit_code == 0, output
assert payload["deleted"] is False
still_there = _read_note(note["permalink"])
assert still_there["title"] == "CLI CamelCase Delete Note"
assert "CaseSensitiveDeleteToken" in still_there["content"]
def test_delete_note_project_id_takes_precedence_over_wrong_project_name(
app, app_config, test_project, config_manager
) -> None:
"""CLI `--project-id` routes destructive operations to the exact project."""
note = _write_note(
"CLI Delete By Project ID",
"delete-cli",
"# CLI Delete By Project ID\n\nProjectIdDeleteToken",
)
exit_code, payload, output = _delete_note(
note["file_path"],
project="not-the-test-project",
project_id=test_project.external_id,
)
assert exit_code == 0, output
assert payload["deleted"] is True
assert payload["title"] == "CLI Delete By Project ID"
assert _read_note(note["permalink"])["title"] is None
def test_delete_note_memory_url_detects_project_from_identifier(
app, app_config, test_project, config_manager
) -> None:
"""A memory:// URL can select the project without a separate --project flag."""
note = _write_note(
"CLI Delete Memory URL",
"delete-cli",
"# CLI Delete Memory URL\n\nMemoryUrlDeleteToken",
project=test_project.name,
)
memory_url = f"memory://{test_project.name}/{note['permalink']}"
exit_code, payload, output = _delete_note(memory_url)
assert exit_code == 0, output
assert payload["deleted"] is True
assert payload["permalink"] == note["permalink"]
assert _read_note(note["permalink"], project=test_project.name)["title"] is None
def test_delete_directory_removes_nested_files_database_records_and_search_results(
app, app_config, test_project, config_manager
) -> None:
"""Directory deletion removes nested notes and reports a complete JSON summary."""
notes = [
_write_note(
"CLI Delete Directory Root",
"delete-cli-dir",
"# CLI Delete Directory Root\n\nDirectoryDeleteTokenRoot",
),
_write_note(
"CLI Delete Directory Child",
"delete-cli-dir/child",
"# CLI Delete Directory Child\n\nDirectoryDeleteTokenChild",
),
_write_note(
"CLI Delete Directory Deep Child",
"delete-cli-dir/child/deep",
"# CLI Delete Directory Deep Child\n\nDirectoryDeleteTokenDeep",
),
]
note_paths = [_project_file(test_project, note["file_path"]) for note in notes]
assert all(path.exists() for path in note_paths)
exit_code, payload, output = _delete_note("delete-cli-dir", is_directory=True, local=True)
assert exit_code == 0, output
assert payload["deleted"] is True
assert payload["is_directory"] is True
assert payload["identifier"] == "delete-cli-dir"
assert payload["total_files"] == 3
assert payload["successful_deletes"] == 3
assert payload["failed_deletes"] == 0
assert payload["errors"] == []
assert set(payload["deleted_files"]) == {note["file_path"] for note in notes}
assert not any(path.exists() for path in note_paths)
for note in notes:
assert _read_note(note["permalink"])["title"] is None
search = _search_notes("CLI Delete Directory", mode_flag="--title")
assert search["total"] == 0
assert search["results"] == []
def test_delete_directory_without_flag_does_not_delete_child_notes(
app, app_config, test_project, config_manager
) -> None:
"""The CLI must not treat a directory path as destructive without --is-directory."""
note = _write_note(
"CLI Delete Directory Safety",
"delete-cli-safety",
"# CLI Delete Directory Safety\n\nDirectorySafetyToken",
)
exit_code, payload, output = _delete_note("delete-cli-safety")
assert exit_code == 0, output
assert payload["deleted"] is False
still_there = _read_note(note["permalink"])
assert still_there["title"] == "CLI Delete Directory Safety"
assert _project_file(test_project, note["file_path"]).exists()
def test_delete_note_rejects_conflicting_routing_flags(
app, app_config, test_project, config_manager
) -> None:
"""delete-note validates the same --local/--cloud conflict as other tool commands."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "delete-cli/missing-note", "--local", "--cloud"],
)
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
@@ -0,0 +1,94 @@
"""Integration coverage for `bm tool write-note --type` (Issue #875)."""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def test_write_note_type_flag_round_trip(app, app_config, test_project, config_manager):
"""`--type` sets the persisted note type and is searchable via `--type`."""
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"CLI Typed Note",
"--folder",
"typed",
"--content",
"# CLI Typed Note\n\nCliTypeToken body.",
"--type",
"guide",
],
)
assert write_result.exit_code == 0, write_result.output
write_data = json.loads(write_result.stdout)
permalink = write_data["permalink"]
# Read back the frontmatter to confirm the persisted type.
read_result = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--include-frontmatter"],
)
assert read_result.exit_code == 0, read_result.output
read_data = json.loads(read_result.stdout)
assert read_data["frontmatter"]["type"] == "guide"
# The search note-type filter must return the typed note.
search_result = runner.invoke(
cli_app,
[
"tool",
"search-notes",
"CliTypeToken",
"--type",
"guide",
"--local",
"--page-size",
"20",
],
)
assert search_result.exit_code == 0, search_result.output
search_data = json.loads(search_result.stdout)
permalinks = {item["permalink"] for item in search_data["results"]}
assert permalink in permalinks
def test_write_note_content_frontmatter_type_wins_over_flag(
app, app_config, test_project, config_manager
):
"""A `type:` in content frontmatter takes precedence over `--type` (documented behavior)."""
content = "---\ntype: session\n---\n# Frontmatter Wins\n\nFrontmatterWinsToken body."
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Frontmatter Wins",
"--folder",
"typed",
"--content",
content,
"--type",
"guide",
],
)
assert write_result.exit_code == 0, write_result.output
write_data = json.loads(write_result.stdout)
permalink = write_data["permalink"]
read_result = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--include-frontmatter"],
)
assert read_result.exit_code == 0, read_result.output
read_data = json.loads(read_result.stdout)
# Content frontmatter "session" wins over the --type "guide" flag.
assert read_data["frontmatter"]["type"] == "session"
@@ -0,0 +1,43 @@
"""Integration test for `bm status --wait` against a real local project.
Unlike the unit tests in tests/cli/test_json_output.py (which mock get_status
to drive deterministic poll sequences), this exercises the full stack: the CLI
runs a real disk-vs-DB scan via the API/repository layer. After write-note
indexes a file, the project is already in sync, so --wait observes total == 0
on the first poll and exits 0 immediately.
"""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def test_status_wait_returns_once_indexed(app, app_config, test_project, config_manager):
"""status --wait exits 0 with total == 0 when the project is fully indexed."""
# Write (and index) a note so the project has real content on disk + in DB.
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Wait Test Note",
"--folder",
"test-notes",
"--content",
"# Wait Test\n\nContent that should be indexed.",
],
)
assert write_result.exit_code == 0, write_result.output
# --wait should observe a synced project (total == 0) and exit immediately.
result = runner.invoke(cli_app, ["status", "--wait", "--json"])
assert result.exit_code == 0, result.output
start = result.output.index("{")
data = json.loads(result.output[start:])
assert data["total"] == 0
+223
View File
@@ -4,6 +4,8 @@ Integration tests for move_note MCP tool.
Tests the complete move note workflow: MCP client -> MCP server -> FastAPI -> database -> file system
"""
import json
import pytest
from fastmcp import Client
@@ -769,3 +771,224 @@ async def test_move_note_strict_resolution_rejects_fuzzy_match(mcp_server, app,
{"project": test_project.name, "identifier": "Move Strict Test B"},
)
assert "Content B" in read_b.content[0].text
@pytest.mark.asyncio
async def test_move_note_unknown_workspace_shaped_path_allowed(mcp_server, app, test_project):
"""A "<seg>/projects/<seg>/..." destination whose leading segment is NOT a known
project is a legitimate same-project nested move and must succeed.
The old "projects"-segment structural heuristic (#904) wrongly rejected this shape
even though it is structurally identical to a normal nested folder. Detection now
relies only on the leading segment matching a known project name (Detection 1) plus
the post-move outcome backstop, so a bare "other-workspace/projects/x/..." that
matches no known project is treated as a normal nested move.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Workspace Shape Note",
"directory": "source",
"content": "# Workspace Shape Note\n\nNested move into a projects folder.",
},
)
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Workspace Shape Note",
"destination_path": "other-workspace/projects/x/moved-note.md",
},
)
move_text = move_result.content[0].text
assert "Cross-Project Move Not Supported" not in move_text
assert "✅ Note moved successfully" in move_text
assert "other-workspace/projects/x/moved-note.md" in move_text
# The note landed at the requested nested location.
read_moved = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "other-workspace/projects/x/moved-note.md",
},
)
assert "Nested move into a projects folder" in read_moved.content[0].text
@pytest.mark.asyncio
async def test_move_note_destination_folder_boundary_rejected(mcp_server, app, test_project):
"""The destination_folder bypass (#881 Gap 3) must now be detected honestly.
Previously the cross-boundary guard ran before destination_folder was resolved into
destination_path, so a boundary-shaped folder slipped through and reported success.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"create_memory_project",
{
"project_name": "boundary-target-project",
"project_path": "/tmp/boundary-target-project",
"set_default": False,
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Folder Boundary Note",
"directory": "source",
"content": "# Folder Boundary Note\n\nFolder bypass should be caught.",
},
)
# destination_folder names another project — resolved path routes cross-project.
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Folder Boundary Note",
"destination_folder": "boundary-target-project",
},
)
error_message = move_result.content[0].text
assert "Cross-Project Move Not Supported" in error_message
assert "boundary-target-project" in error_message
# Note stays put.
read_original = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Folder Boundary Note"},
)
assert "Folder bypass should be caught" in read_original.content[0].text
@pytest.mark.asyncio
async def test_move_note_unknown_workspace_shaped_path_allowed_json(mcp_server, app, test_project):
"""JSON output for an unknown-workspace-shaped destination reports moved=True.
With the ambiguous "projects"-segment heuristic removed (#904), a
"<seg>/projects/<seg>/..." path whose leading segment is not a known project is a
legitimate same-project nested move, not a cross-project rejection.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Workspace Shape JSON Note",
"directory": "source",
"content": "# Workspace Shape JSON Note\n\nJSON path.",
},
)
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Workspace Shape JSON Note",
"destination_path": "team-space/projects/alpha/moved.md",
"output_format": "json",
},
)
data = json.loads(move_result.content[0].text)
assert data["moved"] is True
# Success JSON does not include an error key.
assert "error" not in data
assert data["file_path"] == "team-space/projects/alpha/moved.md"
@pytest.mark.asyncio
async def test_move_note_new_nested_folder_still_succeeds(mcp_server, app, test_project):
"""A legitimate same-project move into a brand-new nested folder must still succeed.
Guards against false positives from the broadened cross-boundary detection. Note the
top-level "projects/" folder is a valid same-project location and must NOT be flagged.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Legit Nested Note",
"directory": "source",
"content": "# Legit Nested Note\n\nValid same-project nested move.",
},
)
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Legit Nested Note",
"destination_path": "projects/2025/q2/legit-nested-note.md",
},
)
move_text = move_result.content[0].text
assert "✅ Note moved successfully" in move_text
assert "projects/2025/q2/legit-nested-note.md" in move_text
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "projects/2025/q2/legit-nested-note.md",
},
)
assert "Valid same-project nested move" in read_result.content[0].text
@pytest.mark.asyncio
async def test_move_note_interior_projects_segment_still_succeeds(mcp_server, app, test_project):
"""A path containing an interior 'projects' segment must not trip cross-boundary detection.
Regression for the false positive where any path containing a 'projects' segment
(e.g. "notes/projects/my-project/note.md") was rejected. The ambiguous structural
heuristic has been removed (#904): a 'projects' folder anywhere in the path is just a
normal nested folder, so this is a valid same-project move and must succeed.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Interior Projects Note",
"directory": "source",
"content": "# Interior Projects Note\n\nInterior projects segment is fine.",
},
)
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Interior Projects Note",
"destination_path": "team/2026/projects/alpha/interior-projects-note.md",
},
)
move_text = move_result.content[0].text
assert "✅ Note moved successfully" in move_text
assert "team/2026/projects/alpha/interior-projects-note.md" in move_text
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "team/2026/projects/alpha/interior-projects-note.md",
},
)
assert "Interior projects segment is fine" in read_result.content[0].text
@@ -0,0 +1,98 @@
"""Bughunt: search_notes note_types filter is documented case-insensitive but
fails to match capitalized frontmatter `type` values.
"""
import json
from typing import Any
import pytest
from fastmcp import Client
def _json(tool_result) -> Any:
assert len(tool_result.content) == 1
assert tool_result.content[0].type == "text"
return json.loads(tool_result.content[0].text)
@pytest.mark.asyncio
async def test_note_types_filter_is_case_insensitive(mcp_server, app, test_project):
async with Client(mcp_server) as client:
content = (
"---\n"
"title: Capitalized Type Note\n"
"type: Chapter\n"
"---\n"
"# Capitalized Type Note\n\nuniqtoken99 body text\n"
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Capitalized Type Note",
"directory": "types",
"content": content,
},
)
plain = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "uniqtoken99",
"search_type": "text",
"output_format": "json",
},
)
plain_data = _json(plain)
assert plain_data["results"], "note not indexed at all"
res = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "uniqtoken99",
"search_type": "text",
"note_types": ["Chapter"],
"output_format": "json",
},
)
data = _json(res)
titles = [r["title"] for r in data["results"]]
assert "Capitalized Type Note" in titles, (
"note_types filter is documented case-insensitive but did not match the "
f"capitalized frontmatter type 'Chapter'. results={data}"
)
@pytest.mark.asyncio
async def test_note_types_lowercase_control(mcp_server, app, test_project):
"""Control: a lowercase frontmatter type DOES match (proves the bug is casing)."""
async with Client(mcp_server) as client:
content = (
"---\n"
"title: Lowercase Type Note\n"
"type: chapter\n"
"---\n"
"# Lowercase Type Note\n\nuniqtoken88 body text\n"
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Lowercase Type Note",
"directory": "types",
"content": content,
},
)
res = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "uniqtoken88",
"search_type": "text",
"note_types": ["Chapter"],
"output_format": "json",
},
)
data = _json(res)
titles = [r["title"] for r in data["results"]]
assert "Lowercase Type Note" in titles, data
+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)."""
@@ -0,0 +1,154 @@
"""Integration tests locking in write_note note_type behavior (Issue #875).
These exercise the real MCP harness (no mocks) to confirm that:
- content frontmatter ``type:`` is persisted as the note type and is searchable
via the ``note_types`` filter;
- overwriting a note with a different content ``type:`` flips the persisted type.
The CLI ``--type`` passthrough is covered separately in
``test-int/cli/test_cli_tool_write_note_type_integration.py``.
"""
import json
from textwrap import dedent
from typing import Any
import pytest
from fastmcp import Client
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
async def test_write_note_content_type_is_persisted_and_searchable(mcp_server, app, test_project):
"""Content frontmatter ``type:`` persists and is found by the note_types filter."""
note = dedent("""
---
title: Session Log
type: session
---
# Session Log
SessionTypeToken content body.
""").strip()
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Session Log",
"directory": "logs",
"content": note,
"output_format": "json",
},
)
# The persisted frontmatter should report the content-declared type.
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "logs/session-log",
"include_frontmatter": True,
"output_format": "json",
},
)
read_payload = _json_content(read_result)
assert read_payload["frontmatter"]["type"] == "session"
# And the note_types filter must return it (and only it for this token).
search_result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "SessionTypeToken",
"search_type": "text",
"note_types": ["session"],
"output_format": "json",
},
)
search_payload = _json_content(search_result)
permalinks = {item["permalink"] for item in search_payload["results"]}
assert any(p.endswith("logs/session-log") for p in permalinks)
@pytest.mark.asyncio
async def test_write_note_overwrite_flips_persisted_type(mcp_server, app, test_project):
"""Overwriting with a different content ``type:`` flips the persisted note type."""
session_note = dedent("""
---
title: Type Flip
type: session
---
# Type Flip
Original session body.
""").strip()
schema_note = dedent("""
---
title: Type Flip
type: schema
---
# Type Flip
Replacement schema body.
""").strip()
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Type Flip",
"directory": "flip",
"content": session_note,
"output_format": "json",
},
)
before = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "flip/type-flip",
"include_frontmatter": True,
"output_format": "json",
},
)
assert _json_content(before)["frontmatter"]["type"] == "session"
# Overwrite with a different content type.
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Type Flip",
"directory": "flip",
"content": schema_note,
"overwrite": True,
"output_format": "json",
},
)
after = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "flip/type-flip",
"include_frontmatter": True,
"output_format": "json",
},
)
assert _json_content(after)["frontmatter"]["type"] == "schema"
+401
View File
@@ -0,0 +1,401 @@
"""Opt-in live LiteLLM embedding evaluation harness."""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import sys
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from rich.console import Console
from rich.table import Table
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
RELATED_DOCUMENT = "OAuth login refresh tokens keep an authenticated web session active."
DISTRACTOR_DOCUMENT = "A sourdough starter ferments flour and water before bread baking."
QUERY_TEXT = "authentication login token flow"
@dataclass(frozen=True)
class LiteLLMLiveCase:
"""A real LiteLLM embedding model to exercise end-to-end."""
name: str
model: str
dimensions: int
api_key_env: str | None = None
document_input_type: str | None = None
query_input_type: str | None = None
forward_dimensions: bool | None = None
@dataclass(frozen=True)
class LiteLLMLiveResult:
"""Measured result for a live LiteLLM embedding case."""
name: str
model: str
dimensions: int
api_key_env: str | None
document_input_type: str | None
query_input_type: str | None
forward_dimensions: bool | None
related_score: float
distractor_score: float
min_norm: float
max_norm: float
embed_documents_latency_ms: float
embed_query_latency_ms: float
total_latency_ms: float
type ProviderFactory = Callable[..., Any]
def _required_string(case_data: Mapping[str, Any], key: str) -> str:
value = case_data.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"LiteLLM live case must include non-empty string field {key!r}")
return value
def _optional_string(case_data: Mapping[str, Any], key: str) -> str | None:
value = case_data.get(key)
if value is None:
return None
if not isinstance(value, str) or not value:
raise ValueError(f"LiteLLM live case field {key!r} must be a non-empty string")
return value
def _optional_bool(case_data: Mapping[str, Any], key: str) -> bool | None:
value = case_data.get(key)
if value is None:
return None
if not isinstance(value, bool):
raise ValueError(f"LiteLLM live case field {key!r} must be a boolean")
return value
def load_custom_cases(raw: str | None) -> list[LiteLLMLiveCase]:
"""Load additional live model cases from JSON."""
if not raw:
return []
values = json.loads(raw)
if not isinstance(values, list):
raise ValueError("LiteLLM live cases JSON must be an array")
cases: list[LiteLLMLiveCase] = []
for value in values:
if not isinstance(value, dict):
raise ValueError("Each LiteLLM live case must be a JSON object")
case_data: dict[str, Any] = value
dimensions = case_data.get("dimensions")
if type(dimensions) is not int or dimensions <= 0:
raise ValueError("LiteLLM live case must include positive integer field 'dimensions'")
cases.append(
LiteLLMLiveCase(
name=_required_string(case_data, "name"),
model=_required_string(case_data, "model"),
dimensions=dimensions,
api_key_env=_optional_string(case_data, "api_key_env"),
document_input_type=_optional_string(case_data, "document_input_type"),
query_input_type=_optional_string(case_data, "query_input_type"),
forward_dimensions=_optional_bool(case_data, "forward_dimensions"),
)
)
return cases
def configured_cases(
environ: Mapping[str, str] | None = None,
*,
custom_cases_raw: str | None = None,
) -> list[LiteLLMLiveCase]:
"""Return built-in and user-supplied live cases whose credentials are available."""
env = os.environ if environ is None else environ
cases: list[LiteLLMLiveCase] = []
if env.get("OPENAI_API_KEY"):
cases.append(
LiteLLMLiveCase(
name="openai-text-embedding-3-small",
model="openai/text-embedding-3-small",
dimensions=1536,
api_key_env="OPENAI_API_KEY",
)
)
if env.get("COHERE_API_KEY"):
cases.append(
LiteLLMLiveCase(
name="cohere-embed-english-v3",
model="cohere/embed-english-v3.0",
dimensions=1024,
api_key_env="COHERE_API_KEY",
)
)
raw = (
custom_cases_raw
if custom_cases_raw is not None
else env.get("BASIC_MEMORY_TEST_LITELLM_CASES")
)
cases.extend(load_custom_cases(raw))
return cases
def cosine(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity for live ranking sanity checks."""
dot = sum(x * y for x, y in zip(a, b, strict=True))
norm_a = vector_norm(a)
norm_b = vector_norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def vector_norm(vector: list[float]) -> float:
"""Return the Euclidean norm of a vector."""
return math.sqrt(sum(value * value for value in vector))
def assert_valid_vector(vector: list[float], dimensions: int) -> float:
"""Assert provider output is a usable normalized vector and return its norm."""
assert len(vector) == dimensions
assert all(math.isfinite(value) for value in vector)
norm = vector_norm(vector)
assert math.isclose(norm, 1.0, abs_tol=1e-6)
return norm
async def evaluate_case(
case: LiteLLMLiveCase,
*,
environ: Mapping[str, str] | None = None,
provider_factory: ProviderFactory = LiteLLMEmbeddingProvider,
) -> LiteLLMLiveResult:
"""Run one live LiteLLM case and return measured ranking/vector metrics."""
env = os.environ if environ is None else environ
api_key = env.get(case.api_key_env) if case.api_key_env else None
provider = provider_factory(
model_name=case.model,
dimensions=case.dimensions,
batch_size=2,
api_key=api_key,
timeout=60.0,
document_input_type=case.document_input_type,
query_input_type=case.query_input_type,
forward_dimensions=case.forward_dimensions,
)
start = time.perf_counter()
documents_start = time.perf_counter()
vectors = await provider.embed_documents([RELATED_DOCUMENT, DISTRACTOR_DOCUMENT])
documents_elapsed = time.perf_counter() - documents_start
query_start = time.perf_counter()
query_vector = await provider.embed_query(QUERY_TEXT)
query_elapsed = time.perf_counter() - query_start
total_elapsed = time.perf_counter() - start
assert len(vectors) == 2
norms = [assert_valid_vector(vector, case.dimensions) for vector in [*vectors, query_vector]]
related_score = cosine(query_vector, vectors[0])
distractor_score = cosine(query_vector, vectors[1])
assert related_score > distractor_score, (
f"{case.name} ranked the related document at {related_score:.4f}, "
f"not above distractor {distractor_score:.4f}"
)
return LiteLLMLiveResult(
name=case.name,
model=case.model,
dimensions=case.dimensions,
api_key_env=case.api_key_env,
document_input_type=case.document_input_type,
query_input_type=case.query_input_type,
forward_dimensions=case.forward_dimensions,
related_score=related_score,
distractor_score=distractor_score,
min_norm=min(norms),
max_norm=max(norms),
embed_documents_latency_ms=documents_elapsed * 1000,
embed_query_latency_ms=query_elapsed * 1000,
total_latency_ms=total_elapsed * 1000,
)
async def evaluate_cases(
cases: Sequence[LiteLLMLiveCase],
*,
environ: Mapping[str, str] | None = None,
provider_factory: ProviderFactory = LiteLLMEmbeddingProvider,
) -> tuple[list[LiteLLMLiveResult], list[tuple[LiteLLMLiveCase, str]]]:
"""Evaluate cases sequentially and collect all failures instead of failing fast."""
env = os.environ if environ is None else environ
results: list[LiteLLMLiveResult] = []
failures: list[tuple[LiteLLMLiveCase, str]] = []
for case in cases:
if case.api_key_env and not env.get(case.api_key_env):
failures.append((case, f"missing required env var {case.api_key_env}"))
continue
try:
results.append(
await evaluate_case(case, environ=env, provider_factory=provider_factory)
)
except Exception as exc: # pragma: no cover - exercised by live provider failures
failures.append((case, f"{type(exc).__name__}: {exc}"))
return results, failures
def build_results_table(
results: Sequence[LiteLLMLiveResult],
failures: Sequence[tuple[LiteLLMLiveCase, str]],
) -> Table:
"""Build a rich report table for live LiteLLM results."""
table = Table(title="LiteLLM Live Embedding Evaluation", show_lines=False)
table.add_column("Status", no_wrap=True)
table.add_column("Case", style="cyan", no_wrap=True)
table.add_column("Model", style="magenta")
table.add_column("Dims", justify="right")
table.add_column("Roles", no_wrap=True)
table.add_column("Forward dims", no_wrap=True)
table.add_column("Related", justify="right")
table.add_column("Distractor", justify="right")
table.add_column("Norm", justify="right")
table.add_column("Total ms", justify="right")
for result in results:
roles = _roles_label(result.document_input_type, result.query_input_type)
table.add_row(
"[green]PASS[/green]",
result.name,
result.model,
str(result.dimensions),
roles,
_bool_label(result.forward_dimensions),
f"{result.related_score:.4f}",
f"{result.distractor_score:.4f}",
f"{result.min_norm:.4f}-{result.max_norm:.4f}",
f"{result.total_latency_ms:.1f}",
)
for case, reason in failures:
roles = _roles_label(case.document_input_type, case.query_input_type)
table.add_row(
"[red]FAIL[/red]",
case.name,
case.model,
str(case.dimensions),
roles,
_bool_label(case.forward_dimensions),
"-",
"-",
"-",
reason,
)
return table
def _roles_label(document_input_type: str | None, query_input_type: str | None) -> str:
if document_input_type or query_input_type:
return f"{document_input_type or '-'} / {query_input_type or '-'}"
return "auto"
def _bool_label(value: bool | None) -> str:
if value is True:
return "true"
if value is False:
return "false"
return "auto"
def _load_cases_arg(args: argparse.Namespace) -> str | None:
if args.cases_json and args.cases_file:
raise ValueError("Use --cases-json or --cases-file, not both")
if args.cases_json:
return str(args.cases_json)
if args.cases_file:
return Path(args.cases_file).read_text(encoding="utf-8")
return None
async def _async_main(args: argparse.Namespace, environ: Mapping[str, str]) -> int:
console = Console()
# Trigger: this command performs real network calls and can spend API quota.
# Why: keeping the same explicit opt-in guard as pytest prevents accidental
# live calls when someone discovers the harness through `just --list`.
# Outcome: humans get a clear command to run before any provider is called.
if environ.get("BASIC_MEMORY_RUN_LITELLM_INTEGRATION") != "1":
console.print(
"[red]Set BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 to run live LiteLLM "
"provider checks.[/red]"
)
return 2
custom_cases_raw = _load_cases_arg(args)
cases = configured_cases(environ, custom_cases_raw=custom_cases_raw)
if not cases:
console.print(
"[yellow]No LiteLLM live cases configured.[/yellow]\n"
"Set OPENAI_API_KEY, COHERE_API_KEY, or provide custom cases with "
"BASIC_MEMORY_TEST_LITELLM_CASES / --cases-file."
)
return 2
results, failures = await evaluate_cases(cases, environ=environ)
if args.json:
payload = {
"results": [asdict(result) for result in results],
"failures": [{"case": asdict(case), "reason": reason} for case, reason in failures],
}
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
console.print(build_results_table(results, failures))
return 1 if failures else 0
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entrypoint for live LiteLLM evaluation."""
parser = argparse.ArgumentParser(description="Run opt-in live LiteLLM embedding checks")
parser.add_argument(
"--cases-json",
help="JSON array of custom LiteLLM cases; overrides BASIC_MEMORY_TEST_LITELLM_CASES",
)
parser.add_argument(
"--cases-file",
help="Path to a JSON file containing custom LiteLLM cases",
)
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
args = parser.parse_args(argv)
try:
return asyncio.run(_async_main(args, os.environ))
except ValueError as exc:
Console(stderr=True).print(f"[red]{exc}[/red]")
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,123 @@
"""Integration tests for process-wide embedding provider reuse (#872).
A long-running ``basic-memory mcp`` server constructs a new search repository per
request, per sync batch, and per project. Each repository used to derive its own
embedding provider via ``create_embedding_provider()``. If the provider cache key
ever drifted, that reloaded the ~2.3GB FastEmbed/ONNX model and leaked memory in
onnxruntime's CPU arena (which never returns memory to the OS).
These tests use the *real* composition paths ``create_embedding_provider``, the
``create_search_repository`` factory, and the FastAPI deps function
``get_search_repository`` with a real FastEmbed provider. FastEmbed loads the
ONNX model lazily on first embed, so constructing providers/repositories here is
cheap and never touches the native model.
They deliberately use the semantic suite's ``sqlite_engine_factory`` (not the
parent ``engine_factory``): the parent fixture depends on ``postgres_engine``,
which this directory overrides to spin up a Docker testcontainer gated only by a
``docker`` binary on PATH. On Windows CI that binary exists without a usable
daemon, so requesting the parent factory aborts these SQLite-only tests with a
testcontainers Ryuk error (#872 follow-up). Staying on the SQLite factory keeps
them backend-correct and Docker-free.
"""
from __future__ import annotations
from typing import cast
import pytest
from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectEntry
from basic_memory.deps.repositories import get_search_repository
from basic_memory.repository.embedding_provider_factory import (
create_embedding_provider,
reset_embedding_provider_cache,
)
from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
def _semantic_config(project_path) -> BasicMemoryConfig:
"""Build a semantic-enabled FastEmbed config rooted at the test path."""
return BasicMemoryConfig(
env="test",
projects={"test-project": ProjectEntry(path=str(project_path))},
default_project="test-project",
database_backend=DatabaseBackend.SQLITE,
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
)
@pytest.fixture(autouse=True)
def _reset_provider_cache():
reset_embedding_provider_cache()
yield
reset_embedding_provider_cache()
@pytest.mark.asyncio
async def test_factory_resolves_single_provider_across_repositories(
tmp_path, sqlite_engine_factory
):
"""The factory must inject one cached provider into every search repository."""
_engine, session_maker = sqlite_engine_factory
config = _semantic_config(tmp_path)
expected_provider = create_embedding_provider(config)
assert isinstance(expected_provider, FastEmbedEmbeddingProvider)
# Two repositories, mimicking per-request / per-sync construction.
repo_a = cast(
SQLiteSearchRepository,
create_search_repository(session_maker, project_id=1, app_config=config),
)
repo_b = cast(
SQLiteSearchRepository,
create_search_repository(session_maker, project_id=2, app_config=config),
)
# Both repos reuse the exact same cached provider object — no second model load.
assert repo_a._embedding_provider is expected_provider
assert repo_b._embedding_provider is expected_provider
@pytest.mark.asyncio
async def test_deps_path_reuses_cached_provider(tmp_path, sqlite_engine_factory):
"""The real FastAPI deps function must reuse the cached provider, not rebuild it."""
_engine, session_maker = sqlite_engine_factory
config = _semantic_config(tmp_path)
expected_provider = create_embedding_provider(config)
repo = cast(
SQLiteSearchRepository,
await get_search_repository(
session_maker=session_maker,
project_id=1,
app_config=config,
),
)
assert repo._embedding_provider is expected_provider
@pytest.mark.asyncio
async def test_factory_skips_provider_when_semantic_disabled(tmp_path, sqlite_engine_factory):
"""With semantic search off, no provider is created and none is injected."""
_engine, session_maker = sqlite_engine_factory
config = BasicMemoryConfig(
env="test",
projects={"test-project": ProjectEntry(path=str(tmp_path))},
default_project="test-project",
database_backend=DatabaseBackend.SQLITE,
semantic_search_enabled=False,
)
repo = cast(
SQLiteSearchRepository,
create_search_repository(session_maker, project_id=1, app_config=config),
)
assert repo._embedding_provider is None
@@ -0,0 +1,125 @@
"""Tests for the opt-in LiteLLM live evaluation harness."""
from __future__ import annotations
import json
import pytest
from semantic.litellm_live_harness import (
LiteLLMLiveCase,
configured_cases,
evaluate_case,
load_custom_cases,
)
class FakeProvider:
"""Minimal provider double for exercising live harness logic without network calls."""
def __init__(self, **kwargs):
self.kwargs = kwargs
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
assert len(texts) == 2
return [[1.0, 0.0], [0.0, 1.0]]
async def embed_query(self, text: str) -> list[float]:
assert text
return [1.0, 0.0]
class WrongRankingProvider(FakeProvider):
"""Provider double that ranks the distractor document higher."""
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
assert len(texts) == 2
return [[0.0, 1.0], [1.0, 0.0]]
def test_load_custom_cases_accepts_roles_and_dimension_forwarding():
"""Custom JSON should preserve provider-specific role and dimensions options."""
raw = json.dumps(
[
{
"name": "azure-small-512",
"model": "azure/basic-memory-embeddings",
"dimensions": 512,
"api_key_env": "AZURE_API_KEY",
"document_input_type": "passage",
"query_input_type": "query",
"forward_dimensions": True,
}
]
)
cases = load_custom_cases(raw)
assert cases == [
LiteLLMLiveCase(
name="azure-small-512",
model="azure/basic-memory-embeddings",
dimensions=512,
api_key_env="AZURE_API_KEY",
document_input_type="passage",
query_input_type="query",
forward_dimensions=True,
)
]
def test_configured_cases_include_available_built_ins():
"""Exported provider keys should enable the built-in OpenAI and Cohere live cases."""
cases = configured_cases(
{
"OPENAI_API_KEY": "openai-key",
"COHERE_API_KEY": "cohere-key",
}
)
assert [case.name for case in cases] == [
"openai-text-embedding-3-small",
"cohere-embed-english-v3",
]
def test_configured_cases_respects_explicit_empty_environment():
"""Tests should not accidentally inherit live keys when an empty env is passed."""
assert configured_cases({}) == []
@pytest.mark.asyncio
async def test_evaluate_case_reports_metrics_for_valid_provider():
"""A passing case should report dimensions, scores, norms, and latency."""
result = await evaluate_case(
LiteLLMLiveCase(
name="fake-provider",
model="fake/model",
dimensions=2,
api_key_env="FAKE_API_KEY",
),
environ={"FAKE_API_KEY": "secret"},
provider_factory=FakeProvider,
)
assert result.name == "fake-provider"
assert result.dimensions == 2
assert result.related_score == pytest.approx(1.0)
assert result.distractor_score == pytest.approx(0.0)
assert result.min_norm == pytest.approx(1.0)
assert result.max_norm == pytest.approx(1.0)
assert result.total_latency_ms >= 0
@pytest.mark.asyncio
async def test_evaluate_case_rejects_wrong_ranking():
"""A case should fail when the query ranks the distractor document higher."""
with pytest.raises(AssertionError, match="ranked the related document"):
await evaluate_case(
LiteLLMLiveCase(
name="bad-provider",
model="fake/model",
dimensions=2,
),
provider_factory=WrongRankingProvider,
)
@@ -0,0 +1,55 @@
"""Opt-in live LiteLLM provider checks against real embedding APIs."""
from __future__ import annotations
import os
from typing import Any
import pytest
from semantic.litellm_live_harness import LiteLLMLiveCase, configured_cases, evaluate_case
pytestmark = [
pytest.mark.semantic,
pytest.mark.slow,
pytest.mark.live,
pytest.mark.skipif(
os.getenv("BASIC_MEMORY_RUN_LITELLM_INTEGRATION") != "1",
reason="Set BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 to run live LiteLLM tests",
),
]
def _live_cases() -> list[LiteLLMLiveCase | Any]:
"""Return built-in and user-supplied live cases whose credentials are available."""
cases = configured_cases(os.environ)
if cases:
return cases
return [
pytest.param(
None,
marks=pytest.mark.skip(
reason=(
"No LiteLLM live cases configured. Set OPENAI_API_KEY, "
"COHERE_API_KEY, or BASIC_MEMORY_TEST_LITELLM_CASES."
)
),
)
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"case",
_live_cases(),
ids=lambda case: case.name if isinstance(case, LiteLLMLiveCase) else "no-live-cases",
)
async def test_litellm_live_model_embeds_documents_and_queries(
case: LiteLLMLiveCase,
) -> None:
"""A live LiteLLM model should embed documents and rank a related query higher."""
result = await evaluate_case(case)
assert result.related_score > result.distractor_score
+168
View File
@@ -0,0 +1,168 @@
"""Integration regression test for get_embedding_status against a real vec0 table.
Regression for #658: after a successful `bm reindex --embeddings`, `bm project info`
still reported "sqlite-vec is unavailable", "Indexed 0/N", and "Chunks 0", and
recommended an unnecessary reindex.
Root cause: get_embedding_status() ran the vec0 JOIN count queries on a bare pooled
ProjectRepository session that never loaded the sqlite-vec extension, so SQLite raised
"no such module: vec0", which the except block mis-reported as "unavailable".
This test exercises the real failure path: it builds a REAL vec0 virtual table, writes a
real embedding into it via the search repository, then queries get_embedding_status through
a ProjectRepository session that did NOT pre-load the extension (mirroring the bug). The
healthy unit test substitutes a plain regular table for vec0 and therefore does not cover
this path.
"""
import os
import sqlite3
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.services.project_service import ProjectService
def _is_postgres() -> bool:
return os.environ.get("BASIC_MEMORY_TEST_POSTGRES", "").lower() in ("1", "true", "yes")
def _unit_vector(dimensions: int) -> list[float]:
"""Return a deterministic unit-norm vector for the vec0 embedding column."""
# vec0 stores float[dimensions]; the actual values don't matter for the count
# queries, but using a normalized vector keeps the row well-formed.
vec = [0.0] * dimensions
vec[0] = 1.0
return vec
@pytest.mark.asyncio
async def test_embedding_status_reads_real_vec0_table(engine_factory, test_project, config_manager):
"""get_embedding_status must report a populated real vec0 table as healthy.
Before the fix, the vec0 JOIN ran on a session without sqlite-vec loaded and
raised "no such module: vec0", which the except block mapped to
vector_tables_exist=False + reindex_recommended=True.
"""
# Trigger: Postgres test matrix executes the same suite.
# Why: vec0 + per-connection sqlite-vec loading is SQLite-specific.
# Outcome: keep the regression on the backend that can actually hit this path.
if _is_postgres():
pytest.skip("Real vec0 table handling is SQLite-specific.")
# Trigger: Python build without SQLite extension loading (#711 — python.org
# macOS / some Windows interpreters lack enable_load_extension).
# Why: this test creates a REAL vec0 virtual table during setup, which is
# impossible without loading the sqlite-vec extension.
# Outcome: skip the regression as an environment-capability gap; the codebase
# already degrades gracefully in that scenario (covered by the unit test).
_probe = sqlite3.connect(":memory:")
if not hasattr(_probe, "enable_load_extension"):
_probe.close()
pytest.skip(
"Python build does not support SQLite extension loading — "
"cannot create real vec0 tables"
)
_probe.close()
_engine, session_maker = engine_factory
project_id = test_project.id
# --- Build a REAL vec0 table via the search repository ---
# Semantic enabled with a fastembed provider so _ensure_vector_tables creates
# the vec0-backed search_vector_embeddings table (float[384]).
app_config = BasicMemoryConfig(
env="test",
database_backend=DatabaseBackend.SQLITE,
semantic_search_enabled=True,
)
search_repo = SQLiteSearchRepository(
session_maker,
project_id=project_id,
app_config=app_config,
)
await search_repo._ensure_vector_tables()
dimensions = search_repo._vector_dimensions
# --- Seed a real entity + search_index row so counts are non-zero ---
# Use the repository so model-level defaults (external_id) are applied.
entity_repo = EntityRepository(session_maker, project_id=project_id)
entity = await entity_repo.create(
{
"title": "Vec Note",
"note_type": "note",
"content_type": "text/markdown",
"project_id": project_id,
"permalink": "vec-note",
"file_path": "vec-note.md",
}
)
entity_id = entity.id
async with db.scoped_session(session_maker) as session:
await session.execute(
text(
"INSERT INTO search_index "
"(id, entity_id, project_id, type, title, permalink, content_stems, "
"content_snippet, file_path, metadata) "
"VALUES (:id, :eid, :pid, 'entity', 'Vec Note', 'vec-note', "
"'vec content', 'vec snippet', 'vec-note.md', '{}')"
),
{"id": entity_id, "eid": entity_id, "pid": project_id},
)
await session.commit()
# --- Insert a chunk + a real embedding into the vec0 table ---
# _write_embeddings writes the embedding into the vec0 virtual table keyed by
# rowid == chunk id, exactly like the reindex path.
async with db.scoped_session(session_maker) as session:
await search_repo._ensure_sqlite_vec_loaded(session)
chunk_result = await session.execute(
text(
"INSERT INTO search_vector_chunks "
"(entity_id, project_id, chunk_key, chunk_text, source_hash, "
"entity_fingerprint, embedding_model) "
"VALUES (:eid, :pid, 'chunk-1', 'vec content', 'hash', "
"'fp-hash', 'bge-small-en-v1.5') "
"RETURNING id"
),
{"eid": entity_id, "pid": project_id},
)
chunk_id = chunk_result.scalar_one()
await search_repo._write_embeddings(
session,
[(chunk_id, "vec content")],
[_unit_vector(dimensions)],
)
await session.commit()
# Evict the vec-loaded connection from the pool. sqlite-vec is loaded
# per-connection, so disposing forces get_embedding_status onto a brand-new
# connection that never loaded the extension — exactly the #658 bug condition
# (e.g. a fresh `bm project info` process after `bm reindex --embeddings`).
await _engine.dispose()
# --- Query status through a fresh ProjectRepository (no extension preloaded) ---
project_repository = ProjectRepository(session_maker)
project_service = ProjectService(project_repository)
status = await project_service.get_embedding_status(project_id)
assert status.semantic_search_enabled is True
# The vec0 JOIN must succeed, so the table is reported as present and healthy.
assert status.vector_tables_exist is True
assert status.reindex_recommended is False
assert status.reindex_reason is None
# Counts must reflect the real data, not the false "0" from the unavailable path.
assert status.total_indexed_entities == 1
assert status.total_chunks == 1
assert status.total_entities_with_chunks == 1
assert status.total_embeddings == 1
assert status.orphaned_chunks == 0
+69
View File
@@ -0,0 +1,69 @@
"""Integration test for the asyncpg engine-dispose race (issue #831 / #877).
On the Postgres backend (``postgresql+asyncpg``), the "open async engine -> work
-> await engine.dispose()" cycle could crash with
``IndexError: pop from an empty deque`` raised from SQLAlchemy's async dispose
machinery as it races event-loop teardown. This made Postgres + file watcher
unusable (container restart loop).
These tests exercise the real create-engine -> query -> dispose cycle against a
live Postgres instance (testcontainers) for many iterations. The race is
timing-dependent, so we loop enough to be meaningful and assert clean
completion. They are skipped on SQLite, which is unaffected.
Run with:
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest \
test-int/test_postgres_dispose_cycle.py -q
"""
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.config import DatabaseBackend
from basic_memory.db import DatabaseType, _create_postgres_engine
@pytest.mark.asyncio
async def test_create_query_dispose_cycle_is_clean(app_config, db_backend):
"""Repeatedly create -> query -> dispose an asyncpg engine without crashing."""
if db_backend != "postgres":
pytest.skip("Postgres-specific test - asyncpg dispose race does not affect SQLite")
db_url = DatabaseType.get_db_url(app_config.database_path, DatabaseType.POSTGRES, app_config)
# Loop enough iterations to make the timing-dependent dispose race observable.
for _ in range(50):
engine = _create_postgres_engine(db_url, app_config)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT 1"))
assert result.scalar() == 1
# This dispose is the call site that raced loop teardown in #831/#877.
await engine.dispose()
@pytest.mark.asyncio
async def test_shutdown_db_dispose_is_clean(app_config, config_manager, db_backend):
"""Repeated get_or_create_db -> query -> shutdown_db cycles complete cleanly.
Exercises the shielded ``shutdown_db`` teardown path against asyncpg. The
autouse ``cleanup_global_db_after_test`` fixture in conftest restores module
state afterwards.
"""
if db_backend != "postgres":
pytest.skip("Postgres-specific test - asyncpg dispose race does not affect SQLite")
assert app_config.database_backend == DatabaseBackend.POSTGRES
for _ in range(25):
engine, session_maker = await db.get_or_create_db(
app_config.database_path,
db_type=DatabaseType.POSTGRES,
ensure_migrations=False,
config=app_config,
)
async with db.scoped_session(session_maker) as session:
result = await session.execute(text("SELECT 1"))
assert result.scalar() == 1
# Shielded dispose - must not surface the asyncpg deque race.
await db.shutdown_db()
+15 -9
View File
@@ -49,14 +49,16 @@ class SpyEntityRepository:
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
self.entities_by_id = entities_by_id
self.calls: list[list[int]] = []
self.calls: list[tuple[list[int], bool]] = []
async def find_by_ids(self, ids: list[int]):
self.calls.append(ids)
self.calls.append((ids, False))
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
async def find_by_ids_for_hydration(self, ids: list[int]):
self.calls.append(ids)
async def find_by_ids_for_hydration(
self, ids: list[int], *, include_cross_project: bool = False
):
self.calls.append((ids, include_cross_project))
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
@@ -65,13 +67,15 @@ class LightweightOnlyEntityRepository:
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
self.entities_by_id = entities_by_id
self.hydration_calls: list[list[int]] = []
self.hydration_calls: list[tuple[list[int], bool]] = []
async def find_by_ids(self, ids: list[int]):
raise AssertionError("graph hydration must use the lightweight hydration lookup")
async def find_by_ids_for_hydration(self, ids: list[int]):
self.hydration_calls.append(ids)
async def find_by_ids_for_hydration(
self, ids: list[int], *, include_cross_project: bool = False
):
self.hydration_calls.append((ids, include_cross_project))
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
@@ -177,7 +181,8 @@ async def test_to_graph_context_batches_entity_hydration_for_recent_activity():
graph = await to_graph_context(context, entity_repository=repo, page=1, page_size=10)
assert len(repo.calls) == 1, f"Expected 1 entity lookup, got {len(repo.calls)}"
assert set(repo.calls[0]) == {1, 2, 3}
assert set(repo.calls[0][0]) == {1, 2, 3}
assert repo.calls[0][1] is True
first_result = graph.results[0]
first_primary = first_result.primary_result
@@ -272,7 +277,8 @@ async def test_to_graph_context_uses_lightweight_hydration_lookup():
graph = await to_graph_context(context, entity_repository=repo)
assert len(repo.hydration_calls) == 1
assert set(repo.hydration_calls[0]) == {1, 2}
assert set(repo.hydration_calls[0][0]) == {1, 2}
assert repo.hydration_calls[0][1] is True
relation = graph.results[0].related_results[0]
assert isinstance(relation, RelationSummary)
assert relation.from_entity_external_id == "ext-root"
+92
View File
@@ -0,0 +1,92 @@
from pathlib import Path
import pytest
from scripts.validate_skills import parse_frontmatter
def test_parse_frontmatter_rejects_unquoted_mapping_colon(tmp_path: Path) -> None:
skill = tmp_path / "SKILL.md"
skill.write_text(
"\n".join(
[
"---",
"name: bm-qa",
"description: Use when validating fixes. Drives the full loop: map issue to commit.",
"---",
"# Skill",
"",
]
),
encoding="utf-8",
)
with pytest.raises(SystemExit, match="invalid YAML"):
parse_frontmatter(skill)
def test_parse_frontmatter_allows_url_colons_in_plain_values(tmp_path: Path) -> None:
skill = tmp_path / "SKILL.md"
skill.write_text(
"\n".join(
[
"---",
"name: memory-notes",
"description: See https://docs.basicmemory.com for usage.",
"---",
"# Skill",
"",
]
),
encoding="utf-8",
)
frontmatter = parse_frontmatter(skill)
assert frontmatter["description"] == "See https://docs.basicmemory.com for usage."
def test_parse_frontmatter_strips_matching_single_quotes(tmp_path: Path) -> None:
skill = tmp_path / "SKILL.md"
skill.write_text(
"\n".join(
[
"---",
"name: memory-notes",
"description: 'Use when values contain mapping-like text: safely.'",
"---",
"# Skill",
"",
]
),
encoding="utf-8",
)
frontmatter = parse_frontmatter(skill)
assert frontmatter["description"] == "Use when values contain mapping-like text: safely."
def test_parse_frontmatter_keeps_nested_fields_nested(tmp_path: Path) -> None:
schema = tmp_path / "schema.md"
schema.write_text(
"\n".join(
[
"---",
"type: schema",
"entity: Task",
"schema:",
" type: object",
"---",
"# Task",
"",
]
),
encoding="utf-8",
)
frontmatter = parse_frontmatter(schema)
assert frontmatter["type"] == "schema"
assert frontmatter["entity"] == "Task"
assert frontmatter["schema"] == ""
+195 -6
View File
@@ -8,6 +8,7 @@ import typer
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.rclone_commands import TransferPlan
from basic_memory.config import ProjectEntry, ProjectMode
from basic_memory.schemas.cloud import WorkspaceInfo
@@ -19,6 +20,9 @@ runner = CliRunner()
[
["cloud", "sync", "--name", "research"],
["cloud", "bisync", "--name", "research"],
# --project is an alias for --name (issue #817)
["cloud", "sync", "--project", "research"],
["cloud", "bisync", "--project", "research"],
],
)
def test_cloud_sync_commands_skip_explicit_cloud_project_sync(monkeypatch, argv, config_manager):
@@ -31,7 +35,7 @@ def test_cloud_sync_commands_skip_explicit_cloud_project_sync(monkeypatch, argv,
monkeypatch.setattr(project_sync_command, "_require_cloud_credentials", lambda _config: None)
monkeypatch.setattr(
project_sync_command, "_require_personal_workspace", lambda _name, _config: None
project_sync_command, "_require_personal_workspace", lambda _name, _config, **_kwargs: None
)
monkeypatch.setattr(
project_sync_command,
@@ -69,7 +73,7 @@ def test_cloud_bisync_fails_fast_when_sync_entry_disappears(monkeypatch, config_
monkeypatch.setattr(project_sync_command, "_require_cloud_credentials", lambda _config: None)
monkeypatch.setattr(
project_sync_command, "_require_personal_workspace", lambda _name, _config: None
project_sync_command, "_require_personal_workspace", lambda _name, _config, **_kwargs: None
)
monkeypatch.setattr(
project_sync_command,
@@ -138,11 +142,12 @@ def test_cloud_bisync_commands_block_organization_workspace(monkeypatch, argv, c
assert result.exit_code == 1, result.output
output = " ".join(result.output.split())
assert "The bisync operation is only supported on Personal workspaces" in output
assert "Use `bm cloud sync --name research` instead" in output
assert "bm cloud pull --name research" in output
assert "bm cloud push --name research" in output
def test_cloud_sync_allows_organization_workspace(monkeypatch, config_manager):
"""Team workspaces can still use the one-way sync command."""
def test_cloud_sync_blocks_organization_workspace(monkeypatch, config_manager):
"""The destructive mirror `sync` is now Personal-only and blocks Team workspaces."""
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
config = config_manager.load_config()
@@ -158,7 +163,41 @@ def test_cloud_sync_allows_organization_workspace(monkeypatch, config_manager):
monkeypatch.setattr(
project_sync_command,
"get_available_workspaces",
lambda: pytest.fail("sync should not require a personal workspace"),
lambda: _async_value([_workspace("team-tenant", "organization", "team")]),
)
monkeypatch.setattr(
project_sync_command,
"get_mount_info",
lambda: pytest.fail("workspace guard should run before mount lookup"),
)
result = runner.invoke(app, ["cloud", "sync", "--name", "research"])
assert result.exit_code == 1, result.output
output = " ".join(result.output.split())
assert "only supported on Personal workspaces" in output
assert "bm cloud push --name research" in output
assert "bm cloud pull --name research" in output
def test_cloud_sync_allows_personal_workspace(monkeypatch, config_manager):
"""Personal workspaces keep the one-way mirror sync available."""
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
config = config_manager.load_config()
config.cloud_api_key = "bmc_test"
config.projects["research"] = ProjectEntry(
path="/tmp/research",
mode=ProjectMode.CLOUD,
workspace_id="personal-tenant",
local_sync_path="/tmp/research",
)
config_manager.save_config(config)
monkeypatch.setattr(
project_sync_command,
"get_available_workspaces",
lambda: _async_value([_workspace("personal-tenant", "personal", "personal")]),
)
monkeypatch.setattr(
project_sync_command,
@@ -348,6 +387,156 @@ def test_bisync_reset_skips_workspace_check_without_credentials(monkeypatch, tmp
assert "No bisync state found for project 'research'" in result.output
def _stub_transfer_env(monkeypatch, module, *, plan, transfer_result=True, recorder=None):
"""Stub the push/pull dependency chain so only diff/transfer logic is exercised."""
monkeypatch.setattr(module, "_require_cloud_credentials", lambda _config: None)
monkeypatch.setattr(
module,
"get_mount_info",
lambda: _async_value(SimpleNamespace(bucket_name="tenant-bucket")),
)
monkeypatch.setattr(
module,
"_get_cloud_project",
lambda _name: _async_value(SimpleNamespace(name="research", path="research")),
)
monkeypatch.setattr(
module,
"_get_sync_project",
lambda _name, _config, _project_data: (SimpleNamespace(name="research"), "/tmp/research"),
)
monkeypatch.setattr(module, "project_diff", lambda *args, **kwargs: plan)
def _fake_transfer(*args, **kwargs):
if recorder is not None:
recorder["args"] = args
recorder["kwargs"] = kwargs
return transfer_result
monkeypatch.setattr(module, "project_transfer", _fake_transfer)
def test_cloud_pull_aborts_on_conflict_by_default(monkeypatch, config_manager):
"""Pull refuses to clobber: it lists conflicts and exits without transferring."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=["new.md"], conflicts=["notes/dup.md"], dest_only=[], errors=[])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(app, ["cloud", "pull", "--name", "research"])
assert result.exit_code == 1, result.output
output = " ".join(result.output.split())
assert "notes/dup.md" in output
assert "--on-conflict keep-cloud" in output
assert "args" not in recorder # transfer never ran
def test_cloud_pull_clean_transfers(monkeypatch, config_manager):
"""With no conflicts, pull proceeds and reports success."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=["local-only.md"], errors=[])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(app, ["cloud", "pull", "--name", "research"])
assert result.exit_code == 0, result.output
output = " ".join(result.output.lower().split())
assert "research pull completed successfully" in output
# Deletions are surfaced, not propagated
assert "deletions are not propagated" in output
assert recorder["kwargs"]["strategy"] == "fail"
assert recorder["args"][2] == "pull"
def test_cloud_pull_keep_cloud_resolves_conflict(monkeypatch, config_manager):
"""An explicit --on-conflict strategy lets pull proceed through conflicts."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=[], conflicts=["notes/dup.md"], dest_only=[], errors=[])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(
app, ["cloud", "pull", "--name", "research", "--on-conflict", "keep-cloud"]
)
assert result.exit_code == 0, result.output
assert recorder["kwargs"]["strategy"] == "keep-cloud"
def test_cloud_pull_aborts_on_compare_errors(monkeypatch, config_manager):
"""If rclone cannot read/hash files, pull aborts before transferring."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=[], conflicts=[], dest_only=[], errors=["bad.md"])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(app, ["cloud", "pull", "--name", "research"])
assert result.exit_code == 1, result.output
assert "could not compare" in result.output
assert "args" not in recorder # transfer never ran
def test_cloud_push_aborts_on_conflict_by_default(monkeypatch, config_manager):
"""Push aborts on conflicts like a rejected git push (pull first)."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=["new.md"], conflicts=["notes/dup.md"], dest_only=[], errors=[])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(app, ["cloud", "push", "--name", "research"])
assert result.exit_code == 1, result.output
assert "notes/dup.md" in result.output
assert "args" not in recorder
def test_cloud_push_keep_local_resolves_conflict(monkeypatch, config_manager):
"""Push with --on-conflict keep-local overwrites cloud and reports the direction."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
plan = TransferPlan(new=[], conflicts=["notes/dup.md"], dest_only=[], errors=[])
recorder: dict = {}
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder)
result = runner.invoke(
app, ["cloud", "push", "--name", "research", "--on-conflict", "keep-local"]
)
assert result.exit_code == 0, result.output
assert recorder["kwargs"]["strategy"] == "keep-local"
assert recorder["args"][2] == "push"
def test_cloud_push_allows_organization_workspace(monkeypatch, config_manager):
"""push is additive and Team-safe — it must not invoke the Personal-only guard."""
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
config = config_manager.load_config()
config.cloud_api_key = "bmc_test"
config.projects["research"] = ProjectEntry(
path="/tmp/research",
mode=ProjectMode.CLOUD,
workspace_id="team-tenant",
local_sync_path="/tmp/research",
)
config_manager.save_config(config)
plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=[], errors=[])
_stub_transfer_env(monkeypatch, module, plan=plan)
monkeypatch.setattr(
module,
"get_available_workspaces",
lambda: pytest.fail("push/pull must not gate on workspace type"),
)
result = runner.invoke(app, ["cloud", "push", "--name", "research"])
assert result.exit_code == 0, result.output
assert "research push completed successfully" in result.output
async def _async_value(value):
return value
+1 -1
View File
@@ -213,7 +213,7 @@ def test_setup_does_not_partially_write_generated_files_when_target_exists(
)
assert result.exit_code == 1
assert "pass --force to overwrite" in result.output
assert "pass --force to overwrite" in " ".join(result.output.split())
assert not (tmp_path / ".github/workflows/basic-memory.yml").exists()
assert not (tmp_path / ".github/basic-memory/memory-ci-capture.md").exists()
mock_seed.assert_not_awaited()
+5 -1
View File
@@ -15,7 +15,11 @@ def test_bm_version_exits_cleanly():
["uv", "run", "bm", "--version"],
capture_output=True,
text=True,
timeout=10,
# `uv run` startup under full-suite load (especially on Windows runners)
# can exceed a tight 10s budget even though --version short-circuits
# before any heavy work. This test guards against hangs, not a startup
# performance budget, so match the looser --help timeout below.
timeout=20,
cwd=Path(__file__).parent.parent.parent, # Project root
)
assert result.returncode == 0
+10 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
import logfire
@@ -29,8 +30,16 @@ def test_app_callback_registers_command_operation(monkeypatch) -> None:
resource = object()
monkeypatch.setattr(cli_app, "init_cli_logging", lambda: None)
monkeypatch.setattr(cli_app.CliContainer, "create", staticmethod(lambda: object()))
# Container needs a `config` attribute because app_callback passes it to the
# uvloop policy installer; the helper itself is stubbed below for this
# telemetry-focused test.
monkeypatch.setattr(
cli_app.CliContainer, "create", staticmethod(lambda: SimpleNamespace(config=object()))
)
monkeypatch.setattr(cli_app, "set_container", lambda container: None)
# app_callback installs the uvloop policy for the Postgres backend; stub the
# helper so this telemetry test does not depend on event-loop policy state.
monkeypatch.setattr("basic_memory.db.maybe_install_uvloop", lambda config: False)
monkeypatch.setattr(cli_app, "maybe_show_init_line", lambda command_name: None)
monkeypatch.setattr(cli_app, "maybe_show_cloud_promo", lambda command_name: None)
monkeypatch.setattr(cli_app, "maybe_run_periodic_auto_update", lambda command_name: None)
+199
View File
@@ -39,6 +39,21 @@ EDIT_NOTE_RESULT = {
"operation": "append",
}
DELETE_NOTE_RESULT = {
"deleted": True,
"is_directory": False,
"title": "Test Note",
"permalink": "notes/test-note",
"file_path": "notes/Test Note.md",
}
DELETE_DIRECTORY_RESULT = {
"deleted": True,
"is_directory": True,
"directory": "notes/archive",
"deleted_count": 3,
}
BUILD_CONTEXT_RESULT = {
"results": [],
"metadata": {"uri": "test/topic", "depth": 1},
@@ -174,6 +189,58 @@ def test_write_note_with_tags(mock_mcp_write):
assert mock_mcp_write.call_args.kwargs["tags"] == ["python", "async"]
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_type_passthrough(mock_mcp_write):
"""--type forwards to the MCP tool's note_type parameter."""
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Test Note",
"--folder",
"notes",
"--content",
"hello",
"--type",
"guide",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_mcp_write.call_args.kwargs["note_type"] == "guide"
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_type_defaults_to_note(mock_mcp_write):
"""write-note defaults note_type to 'note' when --type is omitted."""
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Test Note",
"--folder",
"notes",
"--content",
"hello",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_mcp_write.call_args.kwargs["note_type"] == "note"
# --- read-note ---
@@ -215,6 +282,138 @@ def test_read_note_include_frontmatter(mock_mcp_read):
assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True
# --- delete-note ---
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None:
"""delete-note outputs valid JSON from MCP tool."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "test-note"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["deleted"] is True
assert data["permalink"] == "notes/test-note"
mock_mcp_delete.assert_called_once()
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
assert mock_mcp_delete.call_args.kwargs["is_directory"] is False
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_DIRECTORY_RESULT,
)
def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None:
"""delete-note --is-directory passes directory mode to MCP."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "notes/archive", "--is-directory"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["is_directory"] is True
assert mock_mcp_delete.call_args.kwargs["is_directory"] is True
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
"is_directory": False,
"identifier": "missing-note",
"error": None,
},
)
def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None:
"""delete-note treats not-found JSON without an error as a successful command."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "missing-note"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["deleted"] is False
assert data["identifier"] == "missing-note"
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
"is_directory": False,
"identifier": "test-note",
"error": "Delete failed",
},
)
def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None:
"""delete-note exits with code 1 when MCP tool returns an error field."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "test-note"],
)
assert result.exit_code == 1
assert "Error: Delete failed" in result.output
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
"is_directory": True,
"identifier": "notes/archive",
"total_files": 3,
"successful_deletes": 2,
"failed_deletes": 1,
"errors": [{"path": "notes/archive/locked.md", "error": "permission denied"}],
},
)
def test_delete_note_directory_partial_failure_exits_nonzero(
mock_mcp_delete: AsyncMock,
) -> None:
"""delete-note --is-directory exits 1 when any directory file remains undeleted."""
result = runner.invoke(
cli_app,
["tool", "delete-note", "notes/archive", "--is-directory"],
)
assert result.exit_code == 1
assert "Error: Directory delete incomplete: 1 file(s) failed" in result.output
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None:
"""--project-id forwards to the MCP tool's project_id parameter."""
uuid = "11111111-1111-1111-1111-111111111111"
result = runner.invoke(
cli_app,
["tool", "delete-note", "test-note", "--project-id", uuid],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_mcp_delete.call_args.kwargs["project_id"] == uuid
# --- edit-note ---
+3
View File
@@ -9,6 +9,7 @@ import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.config import DatabaseBackend
import basic_memory.cli.commands.db as db_cmd # noqa: F401
@@ -21,6 +22,8 @@ def _stub_app_config(*, semantic_search_enabled: bool = True) -> SimpleNamespace
semantic_search_enabled=semantic_search_enabled,
database_path=Path("/tmp/basic-memory.db"),
get_project_mode=lambda project_name: None,
# app_callback reads this to decide whether to install the uvloop policy.
database_backend=DatabaseBackend.SQLITE,
)
+131
View File
@@ -230,6 +230,137 @@ def test_status_json_with_skipped_files(mock_get_client, mock_get_active, mock_c
assert "2025-06-15" in data["skipped_files"][0]["first_failed"]
# ---------------------------------------------------------------------------
# Status --wait
#
# Real watch/sync timing is nondeterministic (filesystem events + background
# indexing), so these tests mock ProjectClient.get_status to drive deterministic
# poll sequences and patch asyncio.sleep to a no-op to avoid wall-clock waits.
# ---------------------------------------------------------------------------
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_succeeds_after_polling(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait polls until total == 0, then exits 0."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
# First poll reports pending changes, second reports a synced project.
get_status = AsyncMock(side_effect=[SYNC_REPORT_WITH_CHANGES, SYNC_REPORT_EMPTY])
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
# Polled twice: pending -> empty.
assert get_status.await_count == 2
# Slept once between the two polls.
assert mock_sleep.await_count == 1
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_times_out(mock_get_client, mock_get_active, mock_config_cls, mock_sleep):
"""bm status --wait exits 1 with a timeout message when never synced."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
# Always pending: --wait should hit the deadline and fail.
get_status = AsyncMock(return_value=SYNC_REPORT_WITH_CHANGES)
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
# timeout=0 makes the deadline immediate: poll once, then time out.
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "0"])
assert result.exit_code == 1
assert "Timed out" in result.output
def test_status_wait_negative_timeout_is_rejected():
"""A negative --timeout fails fast with a usage error instead of a confusing
'Timed out after -5s' message. The guard runs before any client I/O, no mocks needed."""
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "-5"])
assert result.exit_code != 0
# Typer colorizes the flag name with ANSI codes (so the literal "--timeout" is split),
# but the message body renders clean — assert on that.
assert "must be >= 0" in result.output
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_json_reports_total_zero(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait --json emits total: 0 once indexing completes."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
get_status = AsyncMock(side_effect=[SYNC_REPORT_WITH_CHANGES, SYNC_REPORT_EMPTY])
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--json"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = _parse_json_output(result.output)
assert data["total"] == 0
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_json_timeout_emits_error(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait --json on timeout emits a JSON error and exits 1."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
get_status = AsyncMock(return_value=SYNC_REPORT_WITH_CHANGES)
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "0", "--json"])
assert result.exit_code == 1
data = _parse_json_output(result.output)
assert "error" in data
assert "Timed out" in data["error"]
# ---------------------------------------------------------------------------
# Schema validate --json
# ---------------------------------------------------------------------------
+31
View File
@@ -0,0 +1,31 @@
"""Tests for the top-level `bm workspace` stub (issue #821).
The stub redirects users to `bm cloud workspace` instead of letting Typer emit a
bare "No such command 'workspace'." with exit code 2.
"""
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
# Importing the module registers the workspace_stub command on the top-level app.
import basic_memory.cli.commands.workspace # noqa: F401
runner = CliRunner()
@pytest.mark.parametrize(
"argv",
[
["workspace"],
["workspace", "list"],
["workspace", "set-default", "foo"],
],
)
def test_workspace_stub_redirects_to_cloud_workspace(argv):
"""Every `bm workspace ...` form exits 1 and points to `bm cloud workspace`."""
result = runner.invoke(app, argv)
assert result.exit_code == 1, result.output
assert "bm cloud workspace" in result.output
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for the uvloop event-loop policy installer.
Covers the structural fix for issue #831 / #877: the asyncpg engine-dispose
race ("IndexError: pop from an empty deque") is avoided by running the Postgres
backend on uvloop, whose C scheduler has no self._ready.popleft() codepath.
These tests verify the gating logic of ``maybe_install_uvloop`` without touching
a real database. The global event-loop policy is saved and restored around each
test so installing uvloop here cannot leak into the rest of the suite.
"""
import asyncio
import sys
import pytest
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.db import maybe_install_uvloop
@pytest.fixture
def restore_event_loop_policy():
"""Save/restore the global event-loop policy around a test."""
original = asyncio.get_event_loop_policy()
try:
yield
finally:
asyncio.set_event_loop_policy(original)
def _postgres_config() -> BasicMemoryConfig:
return BasicMemoryConfig(
env="test",
database_backend=DatabaseBackend.POSTGRES,
database_url="postgresql+asyncpg://user:pass@localhost/db",
)
def _sqlite_config() -> BasicMemoryConfig:
return BasicMemoryConfig(env="test", database_backend=DatabaseBackend.SQLITE)
@pytest.mark.skipif(sys.platform == "win32", reason="uvloop is not available on Windows")
def test_installs_uvloop_for_postgres_backend(restore_event_loop_policy):
"""uvloop policy is installed when backend is Postgres and uvloop is available."""
import uvloop
installed = maybe_install_uvloop(_postgres_config())
assert installed is True
assert isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy)
def test_no_uvloop_for_sqlite_backend(restore_event_loop_policy):
"""SQLite users keep the default loop - the helper is a no-op."""
before = asyncio.get_event_loop_policy()
installed = maybe_install_uvloop(_sqlite_config())
assert installed is False
# Policy must be unchanged for the default (SQLite) path.
assert asyncio.get_event_loop_policy() is before
@pytest.mark.skipif(sys.platform == "win32", reason="uvloop is not available on Windows")
def test_uvloop_unavailable_is_a_safe_noop(restore_event_loop_policy, monkeypatch):
"""When uvloop cannot be imported the helper returns False without raising."""
import builtins
real_import = builtins.__import__
def _fail_uvloop_import(name, *args, **kwargs):
if name == "uvloop":
raise ImportError("simulated missing uvloop")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _fail_uvloop_import)
before = asyncio.get_event_loop_policy()
installed = maybe_install_uvloop(_postgres_config())
assert installed is False
assert asyncio.get_event_loop_policy() is before
+72
View File
@@ -688,6 +688,78 @@ async def test_batch_indexer_index_markdown_file_can_defer_relation_resolution(
assert source.outgoing_relations[0].to_name == "Deferred Target"
@pytest.mark.asyncio
async def test_batch_indexer_uses_strict_link_resolution_for_deferred_relations(
app_config,
entity_service,
entity_repository,
relation_repository,
search_service,
file_service,
project_config,
monkeypatch,
):
"""Regression: batch indexer's deferred relation resolution must call
resolve_link with strict=True.
Mirror of sync_service.resolve_forward_references. Fuzzy fallback in the
deferred path silently fills in to_id from BM25/ts_rank results, polluting
the graph with confidently-wrong edges. Entity-creation already uses
strict=True; this is the other deferred path.
"""
path = "notes/source.md"
await _create_file(
project_config.home / path,
dedent(
"""
---
title: Source
type: note
---
# Source
- links_to [[never-resolves-target]]
"""
),
)
batch_indexer = _make_batch_indexer(
app_config,
entity_service,
entity_repository,
relation_repository,
search_service,
file_service,
)
original_resolve_link = entity_service.link_resolver.resolve_link
seen_strict: list[object] = []
async def spy_resolve_link(*args, **kwargs):
seen_strict.append(kwargs.get("strict", False))
return await original_resolve_link(*args, **kwargs)
monkeypatch.setattr(entity_service.link_resolver, "resolve_link", spy_resolve_link)
await batch_indexer.index_files(
{path: await _load_input(file_service, path)},
max_concurrent=1,
)
assert seen_strict, "batch indexer did not invoke link_resolver.resolve_link"
assert all(strict is True for strict in seen_strict), (
f"Deferred resolution must call resolve_link(strict=True). Observed: {seen_strict!r}"
)
# The unresolvable relation stayed unresolved.
source = await entity_repository.get_by_file_path(path)
assert source is not None
assert len(source.outgoing_relations) == 1
assert source.outgoing_relations[0].to_id is None
assert source.outgoing_relations[0].to_name == "never-resolves-target"
@pytest.mark.asyncio
async def test_batch_indexer_strips_frontmatter_from_search_content_when_body_is_empty(
app_config,
+1
View File
@@ -91,6 +91,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"output_format",
"note_types",
"entity_types",
"categories",
"after_date",
"metadata_filters",
"tags",
+35
View File
@@ -18,6 +18,7 @@ from basic_memory.mcp.tools import (
recent_activity,
write_note,
)
from basic_memory.schemas.response import DirectoryDeleteError, DirectoryDeleteResult
@pytest.mark.asyncio
@@ -326,6 +327,40 @@ async def test_delete_directory_json_mode_returns_structured_error_on_failure(
assert "simulated directory delete failure" in json_delete["error"]
@pytest.mark.asyncio
async def test_delete_directory_json_mode_reports_partial_delete_failure(
app, test_project, monkeypatch
):
async def mock_delete_directory(self, directory: str):
return DirectoryDeleteResult(
total_files=2,
successful_deletes=1,
failed_deletes=1,
deleted_files=["mode-tests/deleted.md"],
errors=[
DirectoryDeleteError(
path="mode-tests/locked.md",
error="permission denied",
)
],
)
monkeypatch.setattr(KnowledgeClient, "delete_directory", mock_delete_directory)
json_delete = await delete_note(
identifier="mode-tests",
is_directory=True,
project=test_project.name,
output_format="json",
)
assert isinstance(json_delete, dict)
assert json_delete["deleted"] is False
assert json_delete["failed_deletes"] == 1
assert json_delete["deleted_files"] == ["mode-tests/deleted.md"]
assert json_delete["errors"] == [{"path": "mode-tests/locked.md", "error": "permission denied"}]
assert "Directory delete incomplete" in json_delete["error"]
@pytest.mark.asyncio
async def test_move_note_text_and_json_modes(app, test_project):
await write_note(
+148
View File
@@ -35,6 +35,77 @@ async def test_detect_cross_project_move_attempt_is_defensive_on_api_error(monke
assert result is None
@pytest.mark.asyncio
async def test_detect_cross_project_only_flags_known_project_name(monkeypatch):
"""Detection flags only a leading segment that matches a KNOWN project name.
The ambiguous "<seg>/projects/<seg>/..." structural heuristic (#904) was removed
because it could not distinguish the cloud workspace layout from a legitimate nested
folder like 'notes/projects/2025/note.md'. Cross-project detection now fires only when
the first path segment matches another known project name (Detection 1); any 'projects'
segment anywhere in the path is just a normal nested folder.
"""
import importlib
clients_mod = importlib.import_module("basic_memory.mcp.clients")
class _Project:
def __init__(self, name: str) -> None:
self.name = name
class _ProjectList:
projects = [_Project("test-project"), _Project("other-project")]
class MockProjectClient:
def __init__(self, *args, **kwargs):
pass
async def list_projects(self, *args, **kwargs):
return _ProjectList()
monkeypatch.setattr(clients_mod, "ProjectClient", MockProjectClient)
move_note_module = importlib.import_module("basic_memory.mcp.tools.move_note")
# Leading segment is a known OTHER project name -> rejected (Detection 1).
rejected = await move_note_module._detect_cross_project_move_attempt(
client=None,
identifier="source/note",
destination_path="other-project/note.md",
current_project="test-project",
)
assert rejected is not None
assert "Cross-Project Move Not Supported" in rejected
# Workspace-shaped path whose leading segment is NOT a known project -> allowed.
# (Previously rejected by the removed 'projects'-segment heuristic.)
workspace_shaped = await move_note_module._detect_cross_project_move_attempt(
client=None,
identifier="source/note",
destination_path="other-workspace/projects/x/note.md",
current_project="test-project",
)
assert workspace_shaped is None
# Interior 'projects' segment -> allowed (no false positive).
allowed = await move_note_module._detect_cross_project_move_attempt(
client=None,
identifier="source/note",
destination_path="team/2026/projects/alpha/note.md",
current_project="test-project",
)
assert allowed is None
# Top-level 'projects/...' (index 0) -> allowed.
top_level = await move_note_module._detect_cross_project_move_attempt(
client=None,
identifier="source/note",
destination_path="projects/2025/note.md",
current_project="test-project",
)
assert top_level is None
@pytest.mark.asyncio
async def test_move_note_success(app, client, test_project):
"""Test successfully moving a note to a new location."""
@@ -1152,3 +1223,80 @@ class TestMoveNoteDestinationFolder:
assert isinstance(result, str)
assert "Security Validation Error" in result
class TestMoveNoteOutcomeValidation:
"""Test the outcome-vs-intent backstop (#881 Gap 2).
The move service stores the path verbatim relative to the project root, so a
divergence between the requested destination and the resulting file_path cannot
occur through the real stack. We inject a divergent result to exercise the backstop
that prevents a falsely-reported "✅ moved successfully".
"""
@pytest.mark.asyncio
async def test_move_note_outcome_mismatch_reports_failure(
self, app, client, test_project, monkeypatch
):
"""A move whose file_path diverges from the request must NOT report success."""
await write_note(
project=test_project.name,
title="Outcome Mismatch Note",
directory="source",
content="# Outcome Mismatch Note\nContent.",
)
from basic_memory.mcp.clients import KnowledgeClient
real_move_entity = KnowledgeClient.move_entity
async def diverging_move_entity(self, entity_id, destination_path):
# Perform the real move, then return a result that landed elsewhere.
result = await real_move_entity(self, entity_id, destination_path)
return result.model_copy(update={"file_path": "somewhere/else/diverged.md"})
monkeypatch.setattr(KnowledgeClient, "move_entity", diverging_move_entity)
result = await move_note(
project=test_project.name,
identifier="source/outcome-mismatch-note",
destination_path="target/outcome-mismatch-note.md",
)
assert isinstance(result, str)
assert "✅ Note moved successfully" not in result
assert "Unexpected Result Location" in result
assert "target/outcome-mismatch-note.md" in result
assert "somewhere/else/diverged.md" in result
@pytest.mark.asyncio
async def test_move_note_outcome_mismatch_json(self, app, client, test_project, monkeypatch):
"""JSON output for an outcome mismatch reports moved=False with the diagnostic."""
await write_note(
project=test_project.name,
title="Outcome Mismatch JSON",
directory="source",
content="# Outcome Mismatch JSON\nContent.",
)
from basic_memory.mcp.clients import KnowledgeClient
real_move_entity = KnowledgeClient.move_entity
async def diverging_move_entity(self, entity_id, destination_path):
result = await real_move_entity(self, entity_id, destination_path)
return result.model_copy(update={"file_path": "somewhere/else/diverged.md"})
monkeypatch.setattr(KnowledgeClient, "move_entity", diverging_move_entity)
result = await move_note(
project=test_project.name,
identifier="source/outcome-mismatch-json",
destination_path="target/outcome-mismatch-json.md",
output_format="json",
)
assert isinstance(result, dict)
assert result["moved"] is False
assert result["error"] == "MOVE_OUTCOME_MISMATCH"
assert result["file_path"] == "somewhere/else/diverged.md"
+123
View File
@@ -370,6 +370,93 @@ async def test_search_with_entity_type_filter(client, test_project):
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_with_categories_filter(client, test_project):
"""Observation category filter returns only the exact category (#430).
Writes a note whose body has a [requirement] observation and a [decision]
observation that also mentions the word "requirement". The categories filter
must return only the requirement observation.
"""
await write_note(
project=test_project.name,
title="Category Filter Note",
directory="test",
content=(
"# Category Filter Note\n"
"- [requirement] The system must enforce auth on every request\n"
"- [decision] We deferred the auth requirement to next sprint\n"
),
)
response = await search_notes(
project=test_project.name,
query="requirement",
search_type="text",
entity_types=["observation"],
categories=["requirement"],
output_format="json",
)
assert isinstance(response, dict), f"Search failed with error: {response}"
results = response["results"]
assert len(results) > 0
# Every result is a requirement observation; the [decision] row is excluded
# even though its text contains the word "requirement".
assert all(r["type"] == "observation" for r in results)
assert all(r["category"] == "requirement" for r in results)
# A non-matching category yields no results for the same text query.
decision = await search_notes(
project=test_project.name,
query="requirement",
search_type="text",
entity_types=["observation"],
categories=["decision"],
output_format="json",
)
assert isinstance(decision, dict), f"Search failed with error: {decision}"
assert all(r["category"] == "decision" for r in decision["results"])
# The requirement observation must not leak into a decision-scoped search.
assert all("requirement" != r.get("category") for r in decision["results"])
@pytest.mark.asyncio
async def test_search_categories_without_entity_types_returns_observations(client, test_project):
"""categories=[...] WITHOUT entity_types must return the matching observations (#908).
search_notes defaults entity_types to "entity" when unset, but categories only exist on
observations so a category filter without an explicit entity_types would AND the
category against entity rows (which have NULL category) and return nothing. The implicit
default must scope to observations when categories is supplied.
"""
await write_note(
project=test_project.name,
title="Category Default Note",
directory="test",
content=(
"# Category Default Note\n"
"- [requirement] Auth tokens must rotate every 24 hours\n"
"- [decision] We chose JWT for the auth token format\n"
),
)
# Note: no entity_types passed — exercises the implicit default.
response = await search_notes(
project=test_project.name,
query="auth",
search_type="text",
categories=["requirement"],
output_format="json",
)
assert isinstance(response, dict), f"Search failed with error: {response}"
results = response["results"]
assert len(results) > 0, "category-only search must return matching observations"
assert all(r["type"] == "observation" for r in results)
assert all(r["category"] == "requirement" for r in results)
@pytest.mark.asyncio
async def test_search_with_date_filter(client, test_project):
"""Test search with date filter."""
@@ -479,6 +566,42 @@ class TestSearchErrorFormatting:
assert "# Search Failed - Semantic Dependencies Missing" in result
assert "pip install -U basic-memory" in result
def test_format_search_error_corrupt_embedding_model(self):
"""Test formatting for a corrupt/missing FastEmbed model (ONNX NO_SUCHFILE)."""
from basic_memory.config import ConfigManager
from basic_memory.repository.embedding_provider_factory import _resolve_cache_dir
result = _format_search_error_response(
"test-project",
"[ONNXRuntimeError] : 3 : NO_SUCHFILE : Load model from "
"/home/u/.basic-memory/fastembed_cache/models--qdrant--bge-small-en-v1.5-onnx-q/"
"snapshots/abc/model_optimized.onnx failed. File doesn't exist",
"semantic query",
"hybrid",
)
expected_cache_dir = _resolve_cache_dir(ConfigManager().config)
assert "# Search Failed - Embedding Model Missing or Corrupt" in result
# Names the actual resolved cache dir so the user knows what to delete.
assert expected_cache_dir in result
# Offers full-text search as an immediate workaround.
assert 'search_type="text"' in result
def test_format_search_error_load_model_phrase_does_not_overmatch(self):
"""A generic error mentioning 'load model' (no 'from') must not hit the embedding branch.
The marker was tightened from the broad 'load model' to the exact ONNX phrasing
'load model from' so unrelated failures fall through to the generic handler.
"""
result = _format_search_error_response(
"test-project",
"Failed to load model configuration for this project",
"test query",
)
assert "# Search Failed - Embedding Model Missing or Corrupt" not in result
assert "# Search Failed" in result
def test_format_search_error_generic(self):
"""Test formatting for generic errors."""
result = _format_search_error_response("test-project", "unknown error", "test query")
+1
View File
@@ -165,6 +165,7 @@ async def test_search_notes_emits_root_operation_and_project_context(
"has_query": True,
"note_type_filter_count": 0,
"entity_type_filter_count": 0,
"category_filter_count": 0,
"has_filters": True,
"has_tags_filter": True,
"has_status_filter": False,
+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"
)
@@ -1069,6 +1069,41 @@ async def test_find_by_ids_for_hydration_skips_eager_load_options(
assert found[0].external_id == sample_entity.external_id
@pytest.mark.asyncio
async def test_find_by_ids_for_hydration_can_include_cross_project_entities(
entity_repository: EntityRepository, sample_entity: Entity, session_maker
):
"""Context hydration can opt into IDs reached through explicit graph edges."""
async with db.scoped_session(session_maker) as session:
other_project = Project(name="other-project", path="/other")
session.add(other_project)
await session.flush()
other_entity = Entity(
project_id=other_project.id,
title="Other Project Entity",
note_type="test",
permalink="other-project/entity",
file_path="other-project/entity.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(other_entity)
await session.flush()
other_entity_id = other_entity.id
project_scoped = await entity_repository.find_by_ids_for_hydration(
[sample_entity.id, other_entity_id]
)
cross_project = await entity_repository.find_by_ids_for_hydration(
[sample_entity.id, other_entity_id], include_cross_project=True
)
assert {entity.id for entity in project_scoped} == {sample_entity.id}
assert {entity.id for entity in cross_project} == {sample_entity.id, other_entity_id}
@pytest.mark.asyncio
async def test_get_permalink_to_file_path_map(entity_repository: EntityRepository, session_maker):
"""Test getting permalink -> file_path mapping for bulk operations."""
+524 -1
View File
@@ -1,7 +1,9 @@
"""Tests for FastEmbedEmbeddingProvider."""
import builtins
import math
import sys
from dataclasses import dataclass
import pytest
@@ -22,13 +24,20 @@ class _StubTextEmbedding:
last_init_kwargs: dict = {}
last_embed_kwargs: dict = {}
def __init__(self, model_name: str, cache_dir: str | None = None, threads: int | None = None):
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
threads: int | None = None,
enable_cpu_mem_arena: bool | None = None,
):
self.model_name = model_name
self.embed_calls = 0
_StubTextEmbedding.last_init_kwargs = {
"model_name": model_name,
"cache_dir": cache_dir,
"threads": threads,
"enable_cpu_mem_arena": enable_cpu_mem_arena,
}
_StubTextEmbedding.init_count += 1
@@ -118,10 +127,36 @@ async def test_fastembed_provider_passes_runtime_knobs_to_fastembed(monkeypatch)
"model_name": "stub-model",
"cache_dir": "/tmp/fastembed-cache",
"threads": 3,
# onnxruntime CPU mem arena is disabled so transient extra loads free memory (#872)
"enable_cpu_mem_arena": False,
}
assert _StubTextEmbedding.last_embed_kwargs == {"batch_size": 8, "parallel": 2}
@pytest.mark.asyncio
async def test_fastembed_provider_disables_cpu_mem_arena_by_default(monkeypatch):
"""Even with no cache_dir/threads set, the ONNX CPU memory arena must be disabled.
onnxruntime's CPU arena never returns memory to the OS, so a duplicate model
load would leak tens of GB in a long-running process (#872). The provider must
always pass enable_cpu_mem_arena=False to FastEmbed regardless of other knobs.
"""
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _StubTextEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
_StubTextEmbedding.last_init_kwargs = {}
provider = FastEmbedEmbeddingProvider(model_name="stub-model", dimensions=4)
await provider.embed_documents(["arena default"])
assert _StubTextEmbedding.last_init_kwargs == {
"model_name": "stub-model",
"cache_dir": None,
"threads": None,
"enable_cpu_mem_arena": False,
}
@pytest.mark.asyncio
async def test_fastembed_provider_parallel_one_disables_multiprocessing(monkeypatch):
"""parallel=1 should not pass FastEmbed multiprocessing kwargs."""
@@ -148,3 +183,491 @@ async def test_fastembed_provider_parallel_two_passes_multiprocessing(monkeypatc
await provider.embed_documents(["parallel enabled"])
assert _StubTextEmbedding.last_embed_kwargs == {"batch_size": 64, "parallel": 2}
class _UnormalizedVector:
"""Stub vector with norm != 1 (simulates multilingual models like paraphrase-multilingual-*)."""
def __init__(self, values):
self._values = values
def tolist(self):
return self._values
class _UnnormalizedTextEmbedding:
def __init__(self, model_name: str, **_kwargs):
self.model_name = model_name
def embed(self, texts: list[str], **_kwargs):
# Return a vector with norm ~= 2.9 (typical for multilingual MiniLM models)
for _ in texts:
yield _UnormalizedVector([1.5, 2.0, 1.0, 0.5])
@pytest.mark.asyncio
async def test_fastembed_provider_l2_normalizes_output_vectors(monkeypatch):
"""Returned vectors must be unit-normalized regardless of the raw model output.
sqlite_search_repository uses a formula that assumes norm == 1. Models such as
paraphrase-multilingual-MiniLM-L12-v2 return vectors with norm ~2.9, which breaks
cosine similarity scoring. The provider must apply L2 normalization before returning.
"""
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _UnnormalizedTextEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
provider = FastEmbedEmbeddingProvider(model_name="stub-multilingual", dimensions=4)
result = await provider.embed_documents(["some text"])
assert len(result) == 1
norm = math.sqrt(sum(x * x for x in result[0]))
assert abs(norm - 1.0) < 1e-6, f"Expected unit norm, got {norm}"
@pytest.mark.asyncio
async def test_fastembed_provider_zero_vector_does_not_raise(monkeypatch):
"""A zero vector from the model must be returned as-is without a division error."""
class _ZeroEmbedding:
def __init__(self, model_name: str, **_kwargs):
pass
def embed(self, texts: list[str], **_kwargs):
for _ in texts:
yield _UnormalizedVector([0.0, 0.0, 0.0, 0.0])
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _ZeroEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
provider = FastEmbedEmbeddingProvider(model_name="stub-zero", dimensions=4)
result = await provider.embed_documents(["zero vector"])
assert result == [[0.0, 0.0, 0.0, 0.0]]
# --- Self-heal of corrupt/partial model cache (#895) ---
#
# A real interrupted FastEmbed download is non-deterministic and offline-unfriendly, so we
# stub TextEmbedding to (a) advertise an HF source + model_file via _list_supported_models so
# the provider can compute the exact models--<org>--<repo> cache subdir and the artifact name,
# and (b) raise a NO_SUCHFILE-style ONNX error on the first construction. This is the justified
# mock case called out in the task. The purge is gated on a filesystem confirmation that the
# snapshot dir exists but the artifact is missing, so each test stages the cache accordingly.
@dataclass
class _StubModelSource:
hf: str
@dataclass
class _StubModelDescription:
model: str
sources: _StubModelSource
model_file: str = "model_optimized.onnx"
class _SelfHealStubTextEmbedding:
"""Raises a NO_SUCHFILE-style ONNX error on the first N constructions, then succeeds."""
fail_first_n = 1
construct_count = 0
HF_SOURCE = "stub-org/stub-model-onnx-q"
RESOLVED_MODEL = "stub-model"
MODEL_FILE = "model_optimized.onnx"
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
threads: int | None = None,
**_kwargs,
):
type(self).construct_count += 1
if type(self).construct_count <= type(self).fail_first_n:
raise RuntimeError(
"[ONNXRuntimeError] : 3 : NO_SUCHFILE : Load model from "
f"{cache_dir}/models--stub-org--stub-model-onnx-q/snapshots/abc123/"
"model_optimized.onnx failed. File doesn't exist"
)
self.model_name = model_name
def embed(self, texts: list[str], batch_size: int = 64, **kwargs):
for _ in texts:
yield _StubVector([1.0, 0.0, 0.0, 0.0])
@classmethod
def _list_supported_models(cls):
# Include decoys so the resolver's skip branches are exercised: a model with a
# different name (name-mismatch skip) and one with an empty HF source (no-source skip).
return [
_StubModelDescription(
model="some-other-model",
sources=_StubModelSource(hf="other-org/other-model"),
model_file=cls.MODEL_FILE,
),
_StubModelDescription(
model=cls.RESOLVED_MODEL,
sources=_StubModelSource(hf=""),
model_file=cls.MODEL_FILE,
),
_StubModelDescription(
model=cls.RESOLVED_MODEL,
sources=_StubModelSource(hf=cls.HF_SOURCE),
model_file=cls.MODEL_FILE,
),
]
def _install_self_heal_stub(monkeypatch):
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _SelfHealStubTextEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
_SelfHealStubTextEmbedding.construct_count = 0
_SelfHealStubTextEmbedding.fail_first_n = 1
_SelfHealStubTextEmbedding.RESOLVED_MODEL = "stub-model"
@pytest.mark.asyncio
async def test_fastembed_provider_self_heals_corrupt_model_cache(monkeypatch, tmp_path):
"""A NO_SUCHFILE load failure should purge the model cache subdir and retry once."""
_install_self_heal_stub(monkeypatch)
# Simulate the partial-download artifact: the model's HF cache subdir exists on disk
# but is incomplete. The provider must remove exactly this subdir, not the whole cache.
cache_dir = tmp_path / "fastembed_cache"
model_subdir = cache_dir / "models--stub-org--stub-model-onnx-q"
model_subdir.mkdir(parents=True)
(model_subdir / "stale.bin").write_text("partial download")
unrelated_subdir = cache_dir / "models--other--keep-me"
unrelated_subdir.mkdir(parents=True)
(unrelated_subdir / "data.bin").write_text("do not delete")
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
vectors = await provider.embed_documents(["recover after corrupt cache"])
# Construction was attempted exactly twice: the failing load, then the post-purge retry.
assert _SelfHealStubTextEmbedding.construct_count == 2
# The corrupt model subdir was removed; the unrelated model cache was untouched.
assert not model_subdir.exists()
assert unrelated_subdir.exists()
assert (unrelated_subdir / "data.bin").read_text() == "do not delete"
# The retry produced real vectors.
assert len(vectors) == 1
assert len(vectors[0]) == 4
@pytest.mark.asyncio
async def test_fastembed_provider_fails_fast_on_persistent_corrupt_cache(monkeypatch, tmp_path):
"""A second consecutive NO_SUCHFILE failure must fail fast (no infinite retry loop)."""
_install_self_heal_stub(monkeypatch)
# Both constructions fail — the retry does not loop.
_SelfHealStubTextEmbedding.fail_first_n = 2
cache_dir = tmp_path / "fastembed_cache"
model_subdir = cache_dir / "models--stub-org--stub-model-onnx-q"
model_subdir.mkdir(parents=True)
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
with pytest.raises(RuntimeError, match="NO_SUCHFILE"):
await provider.embed_documents(["still broken"])
# Exactly one retry: two total construction attempts, then fail fast.
assert _SelfHealStubTextEmbedding.construct_count == 2
@pytest.mark.asyncio
async def test_fastembed_provider_fails_fast_when_purge_silently_noops(monkeypatch, tmp_path):
"""If rmtree silently fails (e.g. Windows locked files), do not claim success or retry.
shutil.rmtree(ignore_errors=True) can no-op when a file is locked. Treating that as a
successful purge would retry against the same broken cache; instead the load must fail
fast with the original error. We inject a no-op rmtree to simulate the locked-file case.
"""
import basic_memory.repository.fastembed_provider as fastembed_provider
_install_self_heal_stub(monkeypatch)
cache_dir = tmp_path / "fastembed_cache"
model_subdir = cache_dir / "models--stub-org--stub-model-onnx-q"
model_subdir.mkdir(parents=True)
(model_subdir / "stale.bin").write_text("partial download")
# Simulate a deletion that silently fails to remove the directory.
monkeypatch.setattr(
fastembed_provider.shutil, "rmtree", lambda *args, **kwargs: None, raising=True
)
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
with pytest.raises(RuntimeError, match="NO_SUCHFILE"):
await provider.embed_documents(["locked cache"])
# rmtree no-oped, so the subdir survives and no retry was attempted.
assert model_subdir.exists()
assert _SelfHealStubTextEmbedding.construct_count == 1
@pytest.mark.asyncio
async def test_fastembed_provider_does_not_purge_on_unrelated_error(monkeypatch, tmp_path):
"""A non-cache load error must propagate without deleting any cache subdir."""
class _ConfigErrorTextEmbedding:
construct_count = 0
def __init__(self, model_name: str, cache_dir: str | None = None, **_kwargs):
type(self).construct_count += 1
raise ValueError("invalid model configuration")
@classmethod
def _list_supported_models(cls):
return [
_StubModelDescription(
model="stub-model",
sources=_StubModelSource(hf="stub-org/stub-model-onnx-q"),
)
]
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _ConfigErrorTextEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
cache_dir = tmp_path / "fastembed_cache"
model_subdir = cache_dir / "models--stub-org--stub-model-onnx-q"
model_subdir.mkdir(parents=True)
(model_subdir / "keep.bin").write_text("keep")
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
with pytest.raises(ValueError, match="invalid model configuration"):
await provider.embed_documents(["bad config"])
# No retry and no deletion for errors that are not missing-artifact failures.
assert _ConfigErrorTextEmbedding.construct_count == 1
assert model_subdir.exists()
@pytest.mark.asyncio
async def test_fastembed_provider_cold_load_does_not_purge_or_retry(monkeypatch, tmp_path):
"""A cold load (snapshot dir absent) must NOT be misread as corruption.
This is the CI happy-path regression: on a cold model cache the first load can fail
before the model is downloaded, but with no snapshot dir there is nothing corrupt to
purge. The original error must propagate unchanged with no retry, so a normal
not-yet-downloaded model is never deleted.
"""
_install_self_heal_stub(monkeypatch)
cache_dir = tmp_path / "fastembed_cache"
cache_dir.mkdir(parents=True)
# Intentionally do NOT create the model subdir: this is a normal cold load.
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
with pytest.raises(RuntimeError, match="NO_SUCHFILE"):
await provider.embed_documents(["nothing to purge"])
# Only the initial attempt ran — no snapshot dir means no confirmed corruption, no retry.
assert _SelfHealStubTextEmbedding.construct_count == 1
@pytest.mark.asyncio
async def test_fastembed_provider_does_not_purge_when_artifact_present(monkeypatch, tmp_path):
"""A NO_SUCHFILE-shaped error must NOT purge when the artifact is actually on disk.
The error-text gate alone is not enough: if filesystem inspection finds the model
artifact present in the snapshot, the cache is not corrupt and must be left intact.
Re-raise the original error rather than deleting a healthy cache.
"""
_install_self_heal_stub(monkeypatch)
# Construction keeps failing with the NO_SUCHFILE text regardless of cache state.
_SelfHealStubTextEmbedding.fail_first_n = 99
cache_dir = tmp_path / "fastembed_cache"
snapshot_dir = cache_dir / "models--stub-org--stub-model-onnx-q" / "snapshots" / "rev1"
snapshot_dir.mkdir(parents=True)
artifact = snapshot_dir / "model_optimized.onnx"
artifact.write_text("valid model artifact")
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
with pytest.raises(RuntimeError, match="NO_SUCHFILE"):
await provider.embed_documents(["artifact is fine"])
# No purge and no retry: the artifact is present, so the cache is not corrupt.
assert _SelfHealStubTextEmbedding.construct_count == 1
assert artifact.exists()
assert artifact.read_text() == "valid model artifact"
@pytest.mark.asyncio
async def test_fastembed_provider_self_heals_when_current_revision_corrupt(monkeypatch, tmp_path):
"""A corrupt current revision must be detected even when an older revision is complete.
HuggingFace keeps multiple revisions under one models--<repo> tree. Per-revision
inspection is required: a whole-tree rglob would find the OLD revision's artifact and
wrongly conclude the cache is healthy, leaving the broken current snapshot
self-perpetuating (PR #900 review).
"""
_install_self_heal_stub(monkeypatch)
cache_dir = tmp_path / "fastembed_cache"
snapshots = cache_dir / "models--stub-org--stub-model-onnx-q" / "snapshots"
# Old revision: complete (has the artifact).
good_rev = snapshots / "rev_old"
good_rev.mkdir(parents=True)
(good_rev / "model_optimized.onnx").write_text("complete old artifact")
# Current revision: interrupted download — directory present, artifact missing.
bad_rev = snapshots / "rev_current"
bad_rev.mkdir(parents=True)
(bad_rev / "stale.partial").write_text("partial download")
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
vectors = await provider.embed_documents(["recover from mixed-revision cache"])
# The corrupt-current-revision cache was detected (not masked by the old revision),
# purged, and the retry succeeded.
assert _SelfHealStubTextEmbedding.construct_count == 2
assert not (cache_dir / "models--stub-org--stub-model-onnx-q").exists()
assert len(vectors) == 1
@pytest.mark.asyncio
async def test_fastembed_provider_self_heals_with_case_insensitive_model_name(
monkeypatch, tmp_path
):
"""A lower-cased model name must still resolve the HF cache subdir for the purge.
FastEmbed matches model names case-insensitively, so a config like
model="baai/bge-small-en-v1.5" is valid. The purge resolver must mirror that, otherwise
the corrupt subdir resolves to nothing and self-heal silently does nothing.
"""
_install_self_heal_stub(monkeypatch)
# Advertise the model under its canonical mixed-case name.
_SelfHealStubTextEmbedding.RESOLVED_MODEL = "Stub-Model"
cache_dir = tmp_path / "fastembed_cache"
model_subdir = cache_dir / "models--stub-org--stub-model-onnx-q"
model_subdir.mkdir(parents=True)
(model_subdir / "stale.bin").write_text("partial download")
# Configure the provider with the lower-cased spelling.
provider = FastEmbedEmbeddingProvider(
model_name="stub-model", dimensions=4, cache_dir=str(cache_dir)
)
vectors = await provider.embed_documents(["recover with case-insensitive name"])
assert _SelfHealStubTextEmbedding.construct_count == 2
assert not model_subdir.exists()
assert len(vectors) == 1
@pytest.mark.asyncio
async def test_fastembed_provider_fails_fast_without_cache_dir(monkeypatch):
"""Without a configured cache_dir there is nothing to purge, so fail fast."""
_install_self_heal_stub(monkeypatch)
# cache_dir defaults to None — _model_cache_candidates() returns no candidates.
provider = FastEmbedEmbeddingProvider(model_name="stub-model", dimensions=4)
with pytest.raises(RuntimeError, match="NO_SUCHFILE"):
await provider.embed_documents(["no cache dir"])
assert _SelfHealStubTextEmbedding.construct_count == 1
@pytest.mark.asyncio
async def test_factory_loads_native_model_once_across_repo_constructions(monkeypatch):
"""The native ONNX model must load exactly once per process despite reuse (#872).
Counting native model loads requires a stub TextEmbedding that increments a
counter on construction there is no way to observe real ONNX loads otherwise.
This is the justified mock: the rest of the path (factory cache, repository
injection) is exercised with real implementations.
The test resolves the provider several times with a *drifting* CPU budget the
exact condition that previously produced a fresh cache key and a second model
load then builds multiple search repositories and embeds across all of them,
asserting the stub was constructed only once.
"""
from typing import Any, cast
from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectEntry
from basic_memory.repository import embedding_provider_factory as factory_module
from basic_memory.repository.embedding_provider_factory import (
create_embedding_provider,
reset_embedding_provider_cache,
)
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
module = type(sys)("fastembed")
setattr(module, "TextEmbedding", _StubTextEmbedding)
monkeypatch.setitem(sys.modules, "fastembed", module)
_StubTextEmbedding.init_count = 0
reset_embedding_provider_cache()
config = BasicMemoryConfig(
env="test",
projects={"test-project": ProjectEntry(path="/tmp/basic-memory-test")},
default_project="test-project",
database_backend=DatabaseBackend.SQLITE,
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_model="stub-model",
semantic_embedding_dimensions=4,
semantic_embedding_threads=None,
semantic_embedding_parallel=None,
)
try:
# First resolution under one CPU budget.
monkeypatch.setattr(factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(factory_module.os, "cpu_count", lambda: 8)
provider_first = create_embedding_provider(config)
# CPU budget drifts (cgroup throttling) — used to force a second model load.
monkeypatch.setattr(factory_module.os, "process_cpu_count", lambda: 4)
monkeypatch.setattr(factory_module.os, "cpu_count", lambda: 4)
# Build several repositories the way per-request/per-sync code does. Each
# one is injected with the cached provider rather than deriving its own.
# session_maker is unused during construction and during embed_documents,
# so None is sufficient for this provider-identity assertion.
repos = [
cast(
SQLiteSearchRepository,
create_search_repository(cast(Any, None), project_id=project_id, app_config=config),
)
for project_id in (1, 2, 3)
]
for repo in repos:
assert repo._embedding_provider is provider_first
# Embed several times to trigger lazy model loads; because every repo shares
# provider_first, repeated embeds must still construct the stub model once.
for _ in repos:
await provider_first.embed_documents(["auth token session"])
assert _StubTextEmbedding.init_count == 1
finally:
reset_embedding_provider_cache()
+2
View File
@@ -71,6 +71,7 @@ class ConcreteSearchRepo(SearchRepositoryBase):
note_types: Optional[list[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[list[SearchItemType]] = None,
categories: Optional[list[str]] = None,
metadata_filters: Optional[dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -126,6 +127,7 @@ HYBRID_KWARGS: dict[str, Any] = dict(
note_types=None,
after_date=None,
search_item_types=None,
categories=None,
metadata_filters=None,
limit=10,
offset=0,
+483
View File
@@ -0,0 +1,483 @@
"""Tests for LiteLLMEmbeddingProvider and factory litellm branch."""
import builtins
import math
import os
import sys
from types import SimpleNamespace
import pytest
from basic_memory.config import BasicMemoryConfig
from basic_memory.repository.embedding_provider_factory import (
create_embedding_provider,
reset_embedding_provider_cache,
)
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
def _make_embedding_response(inputs: list[str], dim: int = 3):
"""Build a fake litellm.aembedding response matching the real shape."""
data = []
for index, text in enumerate(inputs):
base = float(len(text))
data.append(
SimpleNamespace(
index=index,
embedding=[base + float(d) for d in range(dim)],
)
)
return SimpleNamespace(data=data)
def _install_litellm_stub(monkeypatch, dim: int = 3):
"""Install a fake litellm module and return the mock aembedding callable."""
calls: list[dict] = []
async def _aembedding(**kwargs):
calls.append(kwargs)
return _make_embedding_response(kwargs["input"], dim)
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
return calls
@pytest.fixture(autouse=True)
def _reset_cache():
reset_embedding_provider_cache()
yield
reset_embedding_provider_cache()
@pytest.mark.asyncio
async def test_litellm_provider_embed_query(monkeypatch):
"""embed_query should return a single vector through litellm.aembedding."""
_install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="openai/text-embedding-3-small", batch_size=2, dimensions=3
)
result = await provider.embed_query("hello world")
assert len(result) == 3
assert all(isinstance(v, float) for v in result)
@pytest.mark.asyncio
async def test_litellm_provider_embed_documents(monkeypatch):
"""embed_documents should return vectors for each input text."""
_install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="openai/text-embedding-3-small", batch_size=2, dimensions=3
)
texts = ["first doc", "second doc", "third doc"]
result = await provider.embed_documents(texts)
assert len(result) == 3
assert all(len(v) == 3 for v in result)
@pytest.mark.asyncio
async def test_litellm_provider_empty_input(monkeypatch):
"""embed_documents with empty list should return empty list."""
_install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(dimensions=3)
result = await provider.embed_documents([])
assert result == []
@pytest.mark.asyncio
async def test_litellm_provider_batching(monkeypatch):
"""Provider should split inputs into batches of batch_size."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="openai/text-embedding-3-small", batch_size=2, dimensions=3
)
texts = ["a", "b", "c", "d", "e"]
result = await provider.embed_documents(texts)
assert len(result) == 5
assert len(calls) == 3 # 2 + 2 + 1
@pytest.mark.asyncio
async def test_litellm_provider_api_key_forwarded(monkeypatch):
"""api_key should be passed to litellm.aembedding when set."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="openai/text-embedding-3-small",
api_key="sk-test-key",
dimensions=3,
)
await provider.embed_query("test")
assert calls[0]["api_key"] == "sk-test-key"
@pytest.mark.asyncio
async def test_litellm_provider_api_key_omitted_when_none(monkeypatch):
"""api_key should not appear in kwargs when not set."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(model_name="openai/text-embedding-3-small", dimensions=3)
await provider.embed_query("test")
assert "api_key" not in calls[0]
@pytest.mark.asyncio
async def test_litellm_provider_drop_params_always_set(monkeypatch):
"""drop_params=True should always be in the call kwargs."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(dimensions=3)
await provider.embed_query("test")
assert calls[0]["drop_params"] is True
@pytest.mark.asyncio
async def test_litellm_provider_forwards_configured_dimensions(monkeypatch):
"""Configured output dimensions should be sent to LiteLLM."""
calls = _install_litellm_stub(monkeypatch, dim=4)
provider = LiteLLMEmbeddingProvider(
model_name="openai/text-embedding-3-small",
dimensions=4,
)
await provider.embed_query("test")
assert calls[0]["dimensions"] == 4
@pytest.mark.asyncio
async def test_litellm_provider_forwards_dimensions_when_explicitly_enabled(monkeypatch):
"""Arbitrary Azure/OpenAI deployments can opt in to provider-side dimensions."""
calls = _install_litellm_stub(monkeypatch, dim=768)
provider = LiteLLMEmbeddingProvider(
model_name="azure/basic-memory-embedding",
dimensions=768,
forward_dimensions=True,
)
await provider.embed_query("test")
assert calls[0]["dimensions"] == 768
@pytest.mark.asyncio
async def test_litellm_provider_uses_cohere_document_and_query_input_types(monkeypatch):
"""Cohere v3 embeddings require different input_type values per embedding role."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="cohere/embed-english-v3.0",
batch_size=2,
dimensions=3,
)
await provider.embed_documents(["indexed passage"])
await provider.embed_query("retrieval query")
assert calls[0]["input_type"] == "search_document"
assert calls[1]["input_type"] == "search_query"
@pytest.mark.asyncio
async def test_litellm_provider_does_not_forward_dimensions_to_cohere_v3(monkeypatch):
"""Cohere v3 uses configured dimensions only for schema validation."""
calls = _install_litellm_stub(monkeypatch, dim=1024)
provider = LiteLLMEmbeddingProvider(
model_name="cohere/embed-english-v3.0",
dimensions=1024,
)
await provider.embed_documents(["indexed passage"])
assert "dimensions" not in calls[0]
assert calls[0]["input_type"] == "search_document"
@pytest.mark.asyncio
async def test_litellm_provider_uses_explicit_document_and_query_input_types(monkeypatch):
"""Explicit input_type overrides should support asymmetric providers beyond Cohere."""
calls = _install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(
model_name="nvidia_nim/nvidia/embed-qa-4",
batch_size=2,
dimensions=3,
document_input_type="passage",
query_input_type="query",
)
await provider.embed_documents(["indexed passage"])
await provider.embed_query("retrieval query")
assert calls[0]["input_type"] == "passage"
assert calls[1]["input_type"] == "query"
@pytest.mark.asyncio
async def test_litellm_provider_dimension_mismatch_raises_error(monkeypatch):
"""Provider should fail fast when response dimensions differ from configured."""
_install_litellm_stub(monkeypatch, dim=3)
provider = LiteLLMEmbeddingProvider(dimensions=5)
with pytest.raises(RuntimeError, match="3-dimensional vectors"):
await provider.embed_documents(["test text"])
@pytest.mark.asyncio
async def test_litellm_provider_missing_dependency_raises_actionable_error(monkeypatch):
"""Missing litellm package should raise SemanticDependenciesMissingError."""
monkeypatch.delitem(sys.modules, "litellm", raising=False)
original_import = builtins.__import__
def _raising_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "litellm":
raise ImportError("litellm not installed")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _raising_import)
provider = LiteLLMEmbeddingProvider(model_name="openai/text-embedding-3-small")
with pytest.raises(SemanticDependenciesMissingError):
await provider.embed_query("test")
@pytest.mark.asyncio
async def test_litellm_provider_sets_production_mode_before_import(monkeypatch):
"""Unset LiteLLM mode should not let LiteLLM import load cwd .env files."""
monkeypatch.delitem(sys.modules, "litellm", raising=False)
monkeypatch.delenv("LITELLM_MODE", raising=False)
observed_modes: list[str | None] = []
original_import = builtins.__import__
async def _aembedding(**kwargs):
return _make_embedding_response(kwargs["input"])
def _observing_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "litellm":
observed_modes.append(os.environ.get("LITELLM_MODE"))
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
return module
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _observing_import)
provider = LiteLLMEmbeddingProvider(dimensions=3)
await provider.embed_query("test")
assert observed_modes == ["PRODUCTION"]
assert os.environ["LITELLM_MODE"] == "PRODUCTION"
@pytest.mark.asyncio
async def test_litellm_provider_preserves_explicit_litellm_mode(monkeypatch):
"""An explicit LiteLLM mode should stay under the caller's control."""
monkeypatch.setenv("LITELLM_MODE", "CUSTOM")
_install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(dimensions=3)
await provider.embed_query("test")
assert os.environ["LITELLM_MODE"] == "CUSTOM"
@pytest.mark.asyncio
async def test_litellm_provider_output_ordering(monkeypatch):
"""Vectors should be returned in the same order as input texts.
The mock builds vectors as ``[len(text), len(text)+1, len(text)+2]`` per
input, then the provider L2-normalizes them. Reconstruct the expected
normalized vectors and assert positional match this catches both
ordering regressions and normalization regressions in one go.
"""
_install_litellm_stub(monkeypatch)
provider = LiteLLMEmbeddingProvider(dimensions=3, batch_size=2)
texts = ["short", "a longer text here"]
result = await provider.embed_documents(texts)
def _expected(text: str) -> list[float]:
base = float(len(text))
raw = [base + float(d) for d in range(3)]
norm = math.sqrt(sum(x * x for x in raw))
return [x / norm for x in raw]
assert result[0] == pytest.approx(_expected("short"))
assert result[1] == pytest.approx(_expected("a longer text here"))
def test_factory_selects_litellm_provider():
"""Factory should select LiteLLMEmbeddingProvider for litellm config."""
config = BasicMemoryConfig(
env="test",
projects={"test": "/tmp/basic-memory-test"},
default_project="test",
semantic_search_enabled=True,
semantic_embedding_provider="litellm",
semantic_embedding_model="openai/text-embedding-3-small",
)
provider = create_embedding_provider(config)
assert isinstance(provider, LiteLLMEmbeddingProvider)
assert provider.model_name == "openai/text-embedding-3-small"
def test_factory_maps_default_model_for_litellm():
"""Factory should remap bge-small-en-v1.5 default to openai/text-embedding-3-small."""
config = BasicMemoryConfig(
env="test",
projects={"test": "/tmp/basic-memory-test"},
default_project="test",
semantic_search_enabled=True,
semantic_embedding_provider="litellm",
semantic_embedding_model="bge-small-en-v1.5",
)
provider = create_embedding_provider(config)
assert isinstance(provider, LiteLLMEmbeddingProvider)
assert provider.model_name == "openai/text-embedding-3-small"
def test_factory_forwards_litellm_document_and_query_input_types():
"""Factory should pass role-specific LiteLLM input_type config to the provider."""
config = BasicMemoryConfig(
env="test",
projects={"test": "/tmp/basic-memory-test"},
default_project="test",
semantic_search_enabled=True,
semantic_embedding_provider="litellm",
semantic_embedding_model="nvidia_nim/nvidia/embed-qa-4",
semantic_embedding_dimensions=1024,
semantic_embedding_document_input_type="passage",
semantic_embedding_query_input_type="query",
)
provider = create_embedding_provider(config)
assert isinstance(provider, LiteLLMEmbeddingProvider)
assert provider.dimensions == 1024
assert provider.document_input_type == "passage"
assert provider.query_input_type == "query"
def test_factory_forwards_litellm_dimension_forwarding_flag():
"""Factory should pass explicit LiteLLM dimension forwarding config to the provider."""
config = BasicMemoryConfig(
env="test",
projects={"test": "/tmp/basic-memory-test"},
default_project="test",
semantic_search_enabled=True,
semantic_embedding_provider="litellm",
semantic_embedding_model="azure/basic-memory-embedding",
semantic_embedding_dimensions=768,
semantic_embedding_forward_dimensions=True,
)
provider = create_embedding_provider(config)
assert isinstance(provider, LiteLLMEmbeddingProvider)
assert provider.forward_dimensions is True
def test_factory_requires_litellm_dimensions_for_custom_models():
"""Custom LiteLLM models need explicit dimensions before vector tables are created."""
config = BasicMemoryConfig(
env="test",
projects={"test": "/tmp/basic-memory-test"},
default_project="test",
semantic_search_enabled=True,
semantic_embedding_provider="litellm",
semantic_embedding_model="cohere/embed-english-v3.0",
)
with pytest.raises(ValueError, match="semantic_embedding_dimensions"):
create_embedding_provider(config)
def test_runtime_log_attrs():
"""runtime_log_attrs should return batch_size and concurrency."""
provider = LiteLLMEmbeddingProvider(batch_size=32, request_concurrency=8)
attrs = provider.runtime_log_attrs()
assert attrs["provider_batch_size"] == 32
assert attrs["request_concurrency"] == 8
@pytest.mark.asyncio
async def test_litellm_provider_l2_normalizes_output_vectors(monkeypatch):
"""Returned vectors must be unit-normalized regardless of backend output.
sqlite_search_repository maps L2 distance to cosine similarity via
``1 - /2``, which is correct only for unit norm. Several backends
routed through LiteLLM (Cohere, Vertex, Bedrock) do not return
normalized vectors, so the provider must normalize at its boundary.
"""
async def _aembedding(**kwargs):
# Raw vector with norm ~3.74 — must be normalized to unit length.
data = [
SimpleNamespace(index=i, embedding=[1.0, 2.0, 3.0]) for i in range(len(kwargs["input"]))
]
return SimpleNamespace(data=data)
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
provider = LiteLLMEmbeddingProvider(dimensions=3)
result = await provider.embed_documents(["some text"])
assert len(result) == 1
norm = math.sqrt(sum(x * x for x in result[0]))
assert abs(norm - 1.0) < 1e-6, f"Expected unit norm, got {norm}"
@pytest.mark.asyncio
async def test_litellm_provider_zero_vector_does_not_raise(monkeypatch):
"""A zero vector from the backend must pass through without a division error."""
async def _aembedding(**kwargs):
data = [
SimpleNamespace(index=i, embedding=[0.0, 0.0, 0.0]) for i in range(len(kwargs["input"]))
]
return SimpleNamespace(data=data)
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
provider = LiteLLMEmbeddingProvider(dimensions=3)
result = await provider.embed_documents(["zero vector"])
assert result == [[0.0, 0.0, 0.0]]
@pytest.mark.asyncio
async def test_litellm_provider_accepts_dict_response_items(monkeypatch):
"""LiteLLM providers may return embedding data as dict items."""
async def _aembedding(**kwargs):
data = [{"index": i, "embedding": [1.0, 0.0, 0.0]} for i in range(len(kwargs["input"]))]
return SimpleNamespace(data=data)
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
provider = LiteLLMEmbeddingProvider(dimensions=3)
result = await provider.embed_documents(["first", "second"])
assert result == [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]
@pytest.mark.asyncio
async def test_litellm_provider_duplicate_index_raises_error(monkeypatch):
"""A backend returning duplicate indexes is malformed and must fail fast."""
async def _aembedding(**kwargs):
# Both items claim index 0 — ambiguous response.
data = [
SimpleNamespace(index=0, embedding=[1.0, 0.0, 0.0]),
SimpleNamespace(index=0, embedding=[0.0, 1.0, 0.0]),
]
return SimpleNamespace(data=data)
module = type(sys)("litellm")
setattr(module, "aembedding", _aembedding)
monkeypatch.setitem(sys.modules, "litellm", module)
provider = LiteLLMEmbeddingProvider(dimensions=3)
with pytest.raises(RuntimeError, match="duplicate vector indexes"):
await provider.embed_documents(["a", "b"])
+63 -2
View File
@@ -523,7 +523,37 @@ async def test_openai_provider_fails_fast_on_malformed_concurrent_batch(monkeypa
def test_embedding_provider_factory_creates_new_provider_for_different_cache_key():
"""Factory should create distinct providers when cache key fields differ."""
"""Factory should create distinct providers when model-identity fields differ."""
config_a = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_model="bge-small-en-v1.5",
)
config_b = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_model="some-other-model",
)
provider_a = create_embedding_provider(config_a)
provider_b = create_embedding_provider(config_b)
assert provider_a is not provider_b
def test_embedding_provider_factory_reuses_provider_when_only_thread_knobs_differ():
"""Thread/parallel knobs tune ONNX execution, not model identity (#872).
A different CPU-derived thread count must not invalidate the process-wide
provider cache and reload the model. Two configs that differ only in the
FastEmbed thread/parallel knobs must return the exact same provider instance.
"""
config_a = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
@@ -531,6 +561,7 @@ def test_embedding_provider_factory_creates_new_provider_for_different_cache_key
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_threads=2,
semantic_embedding_parallel=1,
)
config_b = BasicMemoryConfig(
env="test",
@@ -539,12 +570,42 @@ def test_embedding_provider_factory_creates_new_provider_for_different_cache_key
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_threads=4,
semantic_embedding_parallel=2,
)
provider_a = create_embedding_provider(config_a)
provider_b = create_embedding_provider(config_b)
assert provider_a is not provider_b
assert provider_a is provider_b
def test_embedding_provider_factory_reuses_provider_when_cpu_budget_drifts(monkeypatch):
"""A drifting CPU budget between calls must not reload the model (#872).
Simulates a container/cgroup where the auto-tuned thread count changes between
two calls. Before the fix, this produced a fresh cache key and reloaded the
~2.3GB ONNX model; now the cached singleton is reused.
"""
config = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_threads=None,
semantic_embedding_parallel=None,
)
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 8)
provider_first = create_embedding_provider(config)
# CPU budget shrinks (e.g. cgroup throttling) → auto-tuned thread count changes.
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 4)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 4)
provider_second = create_embedding_provider(config)
assert provider_first is provider_second
def test_embedding_provider_factory_forwards_openai_request_concurrency():
@@ -12,6 +12,7 @@ from sqlalchemy import text
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
import basic_memory.repository.search_repository_base as search_repository_base_module
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
from basic_memory.repository.postgres_search_repository import (
PostgresSearchRepository,
_strip_nul_from_row,
@@ -57,6 +58,30 @@ class StubEmbeddingProviderV2(StubEmbeddingProvider):
model_name = "stub-v2"
class StubLiteLLMEmbeddingProvider(LiteLLMEmbeddingProvider):
"""LiteLLM-shaped provider with deterministic vectors and no network calls."""
def __init__(
self,
*,
document_input_type: str,
query_input_type: str,
) -> None:
super().__init__(
model_name="nvidia_nim/nvidia/embed-qa-4",
dimensions=4,
batch_size=2,
document_input_type=document_input_type,
query_input_type=query_input_type,
)
async def embed_query(self, text: str) -> list[float]:
return StubEmbeddingProvider._vectorize(text)
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [StubEmbeddingProvider._vectorize(text) for text in texts]
def _oversized_entity_content(bullet_count: int) -> str:
"""Build deterministic content that produces many vector chunks."""
lines = ["# Oversized Entity"]
@@ -565,6 +590,97 @@ async def test_postgres_vector_sync_skips_unchanged_and_reembeds_changed_content
assert model_changed_result.embedding_jobs_total == model_changed_result.chunks_total
@pytest.mark.asyncio
async def test_postgres_litellm_role_change_reembeds_existing_chunks(session_maker, test_project):
"""LiteLLM role changes must invalidate existing Postgres vector chunks."""
await _skip_if_pgvector_unavailable(session_maker)
app_config = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
database_backend=DatabaseBackend.POSTGRES,
semantic_search_enabled=True,
)
repo = PostgresSearchRepository(
session_maker,
project_id=test_project.id,
app_config=app_config,
embedding_provider=StubLiteLLMEmbeddingProvider(
document_input_type="passage",
query_input_type="query",
),
)
await repo.init_search_index()
now = datetime.now(timezone.utc)
content = "# Retrieval Roles\n- auth token rotation\n- database schema migration planning"
await repo.index_item(
SearchIndexRow(
project_id=test_project.id,
id=431,
title="LiteLLM Retrieval Roles",
content_stems=content,
content_snippet=content,
permalink="specs/litellm-retrieval-roles",
file_path="specs/litellm-retrieval-roles.md",
type=SearchItemType.ENTITY.value,
entity_id=431,
metadata={"note_type": "spec"},
created_at=now,
updated_at=now,
)
)
initial_result = await repo.sync_entity_vectors_batch([431])
assert initial_result.entities_synced == 1
assert initial_result.entities_skipped == 0
assert initial_result.chunks_total >= 2
assert initial_result.chunks_skipped == 0
assert initial_result.embedding_jobs_total == initial_result.chunks_total
unchanged_result = await repo.sync_entity_vectors_batch([431])
assert unchanged_result.entities_synced == 1
assert unchanged_result.entities_skipped == 1
assert unchanged_result.embedding_jobs_total == 0
assert unchanged_result.chunks_skipped == unchanged_result.chunks_total
role_changed_repo = PostgresSearchRepository(
session_maker,
project_id=test_project.id,
app_config=app_config,
embedding_provider=StubLiteLLMEmbeddingProvider(
document_input_type="document",
query_input_type="query",
),
)
await role_changed_repo.init_search_index()
role_changed_result = await role_changed_repo.sync_entity_vectors_batch([431])
assert role_changed_result.entities_synced == 1
assert role_changed_result.entities_skipped == 0
assert role_changed_result.chunks_skipped == 0
assert role_changed_result.embedding_jobs_total == role_changed_result.chunks_total
async with db.scoped_session(session_maker) as session:
stored_rows = await session.execute(
text(
"SELECT DISTINCT embedding_model "
"FROM search_vector_chunks "
"WHERE project_id = :project_id AND entity_id = :entity_id"
),
{"project_id": test_project.id, "entity_id": 431},
)
embedding_models = {row.embedding_model for row in stored_rows.fetchall()}
assert embedding_models == {
"StubLiteLLMEmbeddingProvider:"
"nvidia_nim/nvidia/embed-qa-4:4:"
"document_input_type=document:"
"query_input_type=query:"
"forward_dimensions=false"
}
@pytest.mark.asyncio
async def test_postgres_vector_sync_shards_oversized_entity_and_resumes(
session_maker, test_project, monkeypatch
@@ -816,3 +932,72 @@ async def test_postgres_metadata_filters_path_parameterized(session_maker, test_
# Nested path should work without SQL injection risk
results = await repo.search(metadata_filters={"schema.confidence": {"$gt": 0.5}})
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_postgres_search_categories_exact_match(session_maker, test_project):
"""categories filter matches the observation category exactly (mirror of #430).
A [decision] observation that merely mentions "requirement" must be excluded
when categories=["requirement"] is requested.
"""
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
now = datetime.now(timezone.utc)
await repo.bulk_index_items(
[
SearchIndexRow(
project_id=test_project.id,
id=70101,
type=SearchItemType.OBSERVATION.value,
content_stems="the auth requirement must be enforced on every call",
content_snippet="the auth requirement must be enforced on every call",
permalink="test/obs/requirement/70101",
file_path="test/obs.md",
entity_id=1,
category="requirement",
metadata={"note_type": "note"},
created_at=now,
updated_at=now,
),
SearchIndexRow(
project_id=test_project.id,
id=70102,
type=SearchItemType.OBSERVATION.value,
content_stems="we deferred the auth requirement to next sprint",
content_snippet="we deferred the auth requirement to next sprint",
permalink="test/obs/decision/70102",
file_path="test/obs.md",
entity_id=1,
category="decision",
metadata={"note_type": "note"},
created_at=now,
updated_at=now,
),
]
)
# Without the category filter, a text search for "requirement" matches both.
text_results = await repo.search(
search_text="requirement",
search_item_types=[SearchItemType.OBSERVATION],
)
assert {r.id for r in text_results} == {70101, 70102}
# With categories=["requirement"], only the requirement observation survives.
filtered = await repo.search(
search_text="requirement",
search_item_types=[SearchItemType.OBSERVATION],
categories=["requirement"],
)
assert {r.id for r in filtered} == {70101}
assert filtered[0].category == "requirement"
# Standalone filter and count both honor the exact category.
filtered_only = await repo.search(categories=["requirement"])
assert {r.id for r in filtered_only} == {70101}
assert await repo.count(categories=["requirement"]) == 1
# Multiple categories union.
multi = await repo.search(categories=["requirement", "decision"])
assert {r.id for r in multi} == {70101, 70102}
@@ -1044,3 +1044,83 @@ async def test_search_item_types_parameterized(search_repository):
results = await search_repository.search(search_item_types=[SearchItemType.ENTITY])
# Should not raise — parameterized query handles enum values safely
assert isinstance(results, list)
async def _index_observation(
search_repository,
*,
row_id: int,
entity_id: int,
category: str,
content: str,
) -> None:
"""Index a single observation row with an explicit category for filter tests."""
now = datetime.now(timezone.utc)
search_row = SearchIndexRow(
id=row_id,
type=SearchItemType.OBSERVATION.value,
content_stems=content,
content_snippet=content,
permalink=f"test/obs/{category}/{row_id}",
file_path="test/obs.md",
entity_id=entity_id,
category=category,
metadata={"note_type": "note"},
created_at=now,
updated_at=now,
project_id=search_repository.project_id,
)
await search_repository.index_item(search_row)
@pytest.mark.asyncio
async def test_search_categories_exact_match(search_repository, search_entity):
"""categories must match the observation category exactly, not by text.
Regression for #430: searching observations for "requirement" used to also
return a [decision] observation that merely mentions the word. The categories
filter scopes results to the exact indexed category.
"""
await _index_observation(
search_repository,
row_id=70001,
entity_id=search_entity.id,
category="requirement",
content="The auth requirement must be enforced on every call",
)
# A decision observation whose text mentions "requirement" but whose category
# is NOT requirement — it must be excluded by an exact-category filter.
await _index_observation(
search_repository,
row_id=70002,
entity_id=search_entity.id,
category="decision",
content="We deferred the auth requirement to next sprint",
)
# Without the category filter, a text search for "requirement" matches both.
text_results = await search_repository.search(
search_text="requirement",
search_item_types=[SearchItemType.OBSERVATION],
)
assert {r.id for r in text_results} == {70001, 70002}
# With categories=["requirement"], only the requirement observation survives.
filtered = await search_repository.search(
search_text="requirement",
search_item_types=[SearchItemType.OBSERVATION],
categories=["requirement"],
)
assert {r.id for r in filtered} == {70001}
assert filtered[0].category == "requirement"
# categories also works as a standalone filter (no text query).
filtered_only = await search_repository.search(categories=["requirement"])
assert {r.id for r in filtered_only} == {70001}
# count mirrors the filtered search.
assert await search_repository.count(categories=["requirement"]) == 1
# Multiple categories union: both observations come back.
multi = await search_repository.search(categories=["requirement", "decision"])
assert {r.id for r in multi} == {70001, 70002}
@@ -83,6 +83,7 @@ class _ConcreteRepo(SearchRepositoryBase):
note_types: list[str] | None = None,
after_date: datetime | None = None,
search_item_types: list[SearchItemType] | None = None,
categories: list[str] | None = None,
metadata_filters: dict[str, Any] | None = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: float | None = None,
@@ -11,6 +11,7 @@ from sqlalchemy import text
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
@@ -296,6 +297,30 @@ async def test_sqlite_vector_sync_skips_unchanged_and_reembeds_changed_content(s
assert model_changed_result.embedding_jobs_total == model_changed_result.chunks_total
def test_sqlite_embedding_model_key_includes_litellm_role_settings():
"""LiteLLM role changes should invalidate previously embedded document chunks."""
repo = _make_sqlite_repo_for_unit_tests()
repo._embedding_provider = LiteLLMEmbeddingProvider(
model_name="nvidia_nim/nvidia/embed-qa-4",
dimensions=1024,
document_input_type="passage",
query_input_type="query",
)
passage_query_key = repo._embedding_model_key()
repo._embedding_provider = LiteLLMEmbeddingProvider(
model_name="nvidia_nim/nvidia/embed-qa-4",
dimensions=1024,
document_input_type="document",
query_input_type="query",
)
document_query_key = repo._embedding_model_key()
assert passage_query_key != document_query_key
assert "document_input_type=passage" in passage_query_key
assert "query_input_type=query" in passage_query_key
@pytest.mark.asyncio
async def test_sqlite_prepare_window_uses_shared_reads_and_serialized_write_scope(monkeypatch):
"""SQLite should batch read-side prepare work but serialize write-side mutations."""
@@ -56,6 +56,7 @@ class ConcreteSearchRepo(SearchRepositoryBase):
note_types: list[str] | None = None,
after_date: datetime | None = None,
search_item_types: list[SearchItemType] | None = None,
categories: list[str] | None = None,
metadata_filters: dict[str, Any] | None = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: float | None = None,
@@ -158,6 +159,7 @@ async def test_page1_scores_gte_page2_scores():
note_types=None,
after_date=None,
search_item_types=None,
categories=None,
metadata_filters=None,
limit=limit,
offset=offset,
@@ -60,6 +60,7 @@ class ConcreteSearchRepo(SearchRepositoryBase):
note_types: Optional[list[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[list[SearchItemType]] = None,
categories: Optional[list[str]] = None,
metadata_filters: Optional[dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -130,6 +131,7 @@ COMMON_SEARCH_KWARGS: dict[str, Any] = dict(
note_types=None,
after_date=None,
search_item_types=None,
categories=None,
metadata_filters=None,
limit=10,
offset=0,

Some files were not shown because too many files have changed in this diff Show More