mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c30922e65 | |||
| e8cda55ddb | |||
| c850450ddf | |||
| ad1f8621f1 | |||
| a7937d4066 | |||
| 9dbe313193 | |||
| e23363606a | |||
| a8d034b940 | |||
| 7c0937f658 | |||
| 8570d96bad | |||
| 480a2d9468 | |||
| df7452e3ba | |||
| 85a8e59d0f | |||
| 07cb7a606b | |||
| 0ef03edde6 | |||
| efe43a10ea | |||
| 4fe6fe09c8 | |||
| 8acdb49a41 | |||
| f7304bf553 | |||
| 5b034f081d | |||
| 271c883ea8 | |||
| 816ee85fb9 | |||
| 3ba3a9504d | |||
| 1667cdc000 | |||
| f916662ff3 | |||
| 0c9800cd3b | |||
| 476239d878 | |||
| 20bb19f4cd | |||
| f6565b9d23 | |||
| b6e8c636ce | |||
| 442fd1523c | |||
| ce4b5d47a0 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
../../.agents/skills/basic-machines-review
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:**
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -31,6 +31,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
status,
|
||||
tool,
|
||||
update,
|
||||
workspace,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == ""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -91,6 +91,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"output_format",
|
||||
"note_types",
|
||||
"entity_types",
|
||||
"categories",
|
||||
"after_date",
|
||||
"metadata_filters",
|
||||
"tags",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 - L²/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"])
|
||||
@@ -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
Reference in New Issue
Block a user