mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46b372c3e1 | |||
| 0a36256f8a | |||
| 36e67e6eec | |||
| f2683291e4 | |||
| 8c05a9ec80 | |||
| a6d8d4c0f6 | |||
| 0239f4abb4 | |||
| 9259a7eb59 | |||
| 55d675e278 | |||
| 6afe4fd0cc | |||
| 113d1b6f1b | |||
| 545804f194 |
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"basic-memory@basicmachines": true
|
||||
}
|
||||
"enabledPlugins": {}
|
||||
}
|
||||
|
||||
+197
-46
@@ -1,25 +1,70 @@
|
||||
name: Tests
|
||||
|
||||
concurrency:
|
||||
group: bm-ci-${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
# pull_request_target runs on the BASE of the PR, not the merge result.
|
||||
# It has write permissions and access to secrets.
|
||||
# It's useful for PRs from forks or automated PRs but requires careful use for security reasons.
|
||||
# See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
|
||||
pull_request_target:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test-sqlite:
|
||||
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
static-checks:
|
||||
name: Static Checks (Python 3.12)
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
just typecheck
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
just lint
|
||||
|
||||
test-sqlite-unit:
|
||||
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
needs: [static-checks]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [ "3.12", "3.13", "3.14" ]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
@@ -37,18 +82,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just (Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y just
|
||||
|
||||
- name: Install just (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
|
||||
choco install just --yes
|
||||
shell: pwsh
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -56,23 +90,63 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev,semantic]"
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run type checks
|
||||
- name: Run tests (SQLite Unit)
|
||||
run: |
|
||||
just typecheck
|
||||
just test-unit-sqlite
|
||||
|
||||
- name: Run linting
|
||||
test-sqlite-integration:
|
||||
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
needs: [static-checks]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
just lint
|
||||
pip install uv
|
||||
|
||||
- name: Run tests (SQLite)
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-sqlite
|
||||
uv venv
|
||||
|
||||
test-postgres:
|
||||
name: Test Postgres (Python ${{ matrix.python-version }})
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests (SQLite Integration)
|
||||
run: |
|
||||
just test-int-sqlite
|
||||
|
||||
test-postgres-unit:
|
||||
name: Test Postgres Unit (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
needs: [static-checks]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -96,10 +170,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y just
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -107,15 +178,57 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev,semantic]"
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests (Postgres via testcontainers)
|
||||
- name: Run tests (Postgres Unit)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
just test-unit-postgres
|
||||
|
||||
coverage:
|
||||
name: Coverage Summary (combined, Python 3.12)
|
||||
test-postgres-integration:
|
||||
name: Test Postgres Integration (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
needs: [static-checks]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.12", "3.13", "3.14" ]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Note: No services section needed - testcontainers handles Postgres in Docker
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests (Postgres Integration)
|
||||
run: |
|
||||
just test-int-postgres
|
||||
|
||||
test-semantic:
|
||||
name: Test Semantic (Python 3.12)
|
||||
timeout-minutes: 45
|
||||
needs: [static-checks]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -133,10 +246,49 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y just
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev,semantic]"
|
||||
|
||||
- name: Run tests (Semantic)
|
||||
run: |
|
||||
just test-semantic
|
||||
|
||||
coverage:
|
||||
name: Coverage Summary (combined, Python 3.12)
|
||||
timeout-minutes: 60
|
||||
needs:
|
||||
- static-checks
|
||||
- test-sqlite-unit
|
||||
- test-sqlite-integration
|
||||
- test-postgres-unit
|
||||
- test-postgres-integration
|
||||
- test-semantic
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -148,7 +300,6 @@ jobs:
|
||||
|
||||
- name: Run combined coverage (SQLite + Postgres)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just coverage
|
||||
|
||||
- name: Add coverage report to job summary
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Add `--strip-frontmatter` to `basic-memory tool read-note`
|
||||
- Default behavior is unchanged: `content` still includes raw markdown with frontmatter.
|
||||
- With `--strip-frontmatter`, both text and JSON modes return body-only markdown content.
|
||||
- JSON output now includes an additive `frontmatter` field with parsed YAML metadata (or `null`
|
||||
when no valid opening frontmatter block exists).
|
||||
|
||||
## v0.18.3 (2026-02-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -344,22 +344,20 @@ basic-memory sync --watch
|
||||
3. Cloud features (optional, requires subscription):
|
||||
|
||||
```bash
|
||||
# Authenticate with cloud (global cloud mode via OAuth)
|
||||
# Authenticate with cloud (stores OAuth token locally)
|
||||
basic-memory cloud login
|
||||
|
||||
# Bidirectional sync with cloud
|
||||
basic-memory cloud sync
|
||||
# (Optional) install/configure rclone for file sync commands
|
||||
basic-memory cloud setup
|
||||
|
||||
# Verify cloud integrity
|
||||
basic-memory cloud check
|
||||
|
||||
# Mount cloud storage
|
||||
basic-memory cloud mount
|
||||
# Check cloud auth + health
|
||||
basic-memory cloud status
|
||||
```
|
||||
|
||||
**Per-Project Cloud Routing** (API key based):
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local. This uses an API key instead of OAuth:
|
||||
Individual projects can be routed through the cloud while others stay local. This uses an API key for routed
|
||||
project calls:
|
||||
|
||||
```bash
|
||||
# Save an API key (create one in the web app or via CLI)
|
||||
@@ -373,43 +371,66 @@ basic-memory project set-cloud research
|
||||
# Revert a project to local mode
|
||||
basic-memory project set-local research
|
||||
|
||||
# List projects with mode column (local/cloud)
|
||||
# List projects and route metadata
|
||||
basic-memory project list
|
||||
```
|
||||
|
||||
**Routing Flags** (for users with global cloud mode):
|
||||
`basic-memory cloud login` / `basic-memory cloud logout` are authentication commands. They do not change default CLI
|
||||
routing behavior.
|
||||
|
||||
When global cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
|
||||
**Routing Flags**:
|
||||
|
||||
Use routing flags to disambiguate command targets:
|
||||
|
||||
```bash
|
||||
# Force local routing (useful for local MCP server while cloud mode is enabled)
|
||||
# Force local routing for this command
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
basic-memory project ls --name main --local
|
||||
|
||||
# Force cloud routing (when cloud mode is disabled but you want cloud access)
|
||||
# Force cloud routing for this command
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
basic-memory project ls --name main --cloud
|
||||
```
|
||||
|
||||
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
|
||||
No-flag behavior defaults to local when no project context is present.
|
||||
|
||||
The local MCP server (`basic-memory mcp`) always uses local routing (including `--transport stdio`).
|
||||
|
||||
**CLI Note Editing (`tool edit-note`):**
|
||||
|
||||
```bash
|
||||
# Append content
|
||||
basic-memory tool edit-note project-plan --operation append --content $'\n## Next Steps\n- Finalize rollout'
|
||||
|
||||
# Find/replace with replacement count validation
|
||||
basic-memory tool edit-note docs/api --operation find_replace --find-text "v0.14.0" --content "v0.15.0" --expected-replacements 2
|
||||
|
||||
# Replace a section body
|
||||
basic-memory tool edit-note docs/setup --operation replace_section --section "## Installation" --content $'Updated install steps\n- Run just install'
|
||||
|
||||
# JSON metadata output for integrations
|
||||
basic-memory tool edit-note docs/setup --operation append --content $'\n- Added note' --format json
|
||||
```
|
||||
|
||||
4. In Claude Desktop, the LLM can now use these tools:
|
||||
|
||||
**Content Management:**
|
||||
```
|
||||
write_note(title, content, folder, tags) - Create or update notes
|
||||
read_note(identifier, page, page_size) - Read notes by title or permalink
|
||||
write_note(title, content, folder, tags, output_format="text"|"json") - Create or update notes
|
||||
read_note(identifier, page, page_size, output_format="text"|"json") - Read notes by title or permalink
|
||||
read_content(path) - Read raw file content (text, images, binaries)
|
||||
view_note(identifier) - View notes as formatted artifacts
|
||||
edit_note(identifier, operation, content) - Edit notes incrementally
|
||||
move_note(identifier, destination_path) - Move notes with database consistency
|
||||
delete_note(identifier) - Delete notes from knowledge base
|
||||
edit_note(identifier, operation, content, output_format="text"|"json") - Edit notes incrementally
|
||||
move_note(identifier, destination_path, output_format="text"|"json") - Move notes with database consistency
|
||||
delete_note(identifier, output_format="text"|"json") - Delete notes from knowledge base
|
||||
```
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
```
|
||||
build_context(url, depth, timeframe) - Navigate knowledge graph via memory:// URLs
|
||||
recent_activity(type, depth, timeframe) - Find recently updated information
|
||||
build_context(url, depth, timeframe, output_format="json"|"text") - Navigate knowledge graph via memory:// URLs
|
||||
recent_activity(type, depth, timeframe, output_format="text"|"json") - Find recently updated information
|
||||
list_directory(dir_name, depth) - Browse directory contents with filtering
|
||||
```
|
||||
|
||||
@@ -422,12 +443,15 @@ search_by_metadata(filters, limit, offset, project) - Structured frontmatter sea
|
||||
|
||||
**Project Management:**
|
||||
```
|
||||
list_memory_projects() - List all available projects
|
||||
create_memory_project(project_name, project_path) - Create new projects
|
||||
list_memory_projects(output_format="text"|"json") - List all available projects
|
||||
create_memory_project(project_name, project_path, output_format="text"|"json") - Create new projects
|
||||
get_current_project() - Show current project stats
|
||||
sync_status() - Check synchronization status
|
||||
```
|
||||
|
||||
`output_format` defaults to `"text"` for these tools, preserving current human-readable responses.
|
||||
`build_context` defaults to `"json"` and can be switched to `"text"` when compact markdown output is preferred.
|
||||
|
||||
**Cloud Discovery (opt-in):**
|
||||
```
|
||||
cloud_info() - Show optional Cloud overview and setup guidance
|
||||
@@ -477,7 +501,9 @@ Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The lo
|
||||
|----------|---------|-------------|
|
||||
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
|
||||
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) |
|
||||
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing |
|
||||
| `BASIC_MEMORY_FORCE_CLOUD` | `false` | When `true`, forces cloud API routing |
|
||||
| `BASIC_MEMORY_EXPLICIT_ROUTING` | `false` | When `true`, marks route selection as explicit (`--local`/`--cloud`) |
|
||||
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
|
||||
|
||||
### Examples
|
||||
|
||||
+8
-10
@@ -18,7 +18,7 @@ Each entrypoint uses a **composition root** pattern to manage configuration and
|
||||
A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that:
|
||||
|
||||
1. Reads configuration from `ConfigManager`
|
||||
2. Resolves runtime mode (cloud/local/test)
|
||||
2. Resolves runtime mode (local/test)
|
||||
3. Creates and provides dependencies to downstream code
|
||||
|
||||
**Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly.
|
||||
@@ -52,10 +52,7 @@ class Container:
|
||||
def create(cls) -> "Container":
|
||||
"""Create container by reading ConfigManager."""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@property
|
||||
@@ -99,18 +96,19 @@ class RuntimeMode(Enum):
|
||||
return self == RuntimeMode.TEST
|
||||
```
|
||||
|
||||
Resolution follows this precedence: **TEST > CLOUD > LOCAL**
|
||||
Resolution follows this precedence in local app flows: **TEST > LOCAL**
|
||||
|
||||
```python
|
||||
def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> RuntimeMode:
|
||||
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
return RuntimeMode.LOCAL
|
||||
```
|
||||
|
||||
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync). Per-project routing is orthogonal — individual projects can be set to `cloud` mode via `ProjectMode` in config, which affects client routing in `get_client(project_name=...)` without changing the global runtime mode.
|
||||
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync).
|
||||
Per-project routing is orthogonal: individual projects can be set to `cloud` mode via `ProjectMode`,
|
||||
which affects client routing in `get_client(project_name=...)` without changing global runtime mode.
|
||||
`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it.
|
||||
|
||||
## Dependencies Package
|
||||
|
||||
|
||||
+108
-148
@@ -1,175 +1,135 @@
|
||||
# Per-Project Local/Cloud Routing
|
||||
# Simplified Local/Cloud Routing
|
||||
|
||||
## Context
|
||||
|
||||
basic-memory's cloud/local mode is currently a global toggle (`cloud_mode: bool`). When enabled, ALL projects route through the cloud proxy via OAuth. This is too coarse — users should be able to keep some projects local and route others through cloud.
|
||||
Basic Memory now uses explicit, project-aware routing without a global cloud-mode toggle.
|
||||
Routing is determined by command-level flags and project mode, not by a global `cloud_mode` state.
|
||||
|
||||
The cloud API already supports API key auth (`bmc_`-prefixed keys, `POST /api/keys` to create, `HybridTokenVerifier` routes them automatically). API keys are per-tenant (account-level), not per-project — there are no per-project permissions in the cloud yet.
|
||||
This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients.
|
||||
|
||||
**Goal**: Users can set each project to `local` or `cloud` mode. Local projects use the existing ASGI in-process transport. Cloud projects use the cloud API with a single account-level API key. No OAuth dance needed for cloud project access.
|
||||
## Goals
|
||||
|
||||
## UX Flow
|
||||
1. Remove global `cloud_mode` from runtime/routing semantics.
|
||||
2. Keep MCP stdio local-only and predictable.
|
||||
3. Make CLI routing explicit and easy to reason about.
|
||||
4. Support projects that exist in both local and cloud without ambiguity.
|
||||
|
||||
**Option A — Create key in web app:**
|
||||
1. User creates API key in cloud web app (already supported)
|
||||
2. Copies the key
|
||||
3. Runs `bm cloud set-key bmc_abc123...` → saves to config.json
|
||||
## Routing Contract
|
||||
|
||||
**Option B — Create key via CLI:**
|
||||
1. User is already logged in via OAuth (`bm cloud login`)
|
||||
2. Runs `bm cloud create-key "my-laptop"` → calls `POST /api/keys` with JWT auth → gets key back → saves to config.json
|
||||
3. OAuth login is no longer needed for day-to-day use — the API key handles auth
|
||||
Routing is resolved in this order:
|
||||
|
||||
**Setting project mode:**
|
||||
```bash
|
||||
bm project set-cloud research # route "research" project through cloud
|
||||
bm project set-local research # revert to local
|
||||
bm project list # shows mode column (local/cloud)
|
||||
```
|
||||
1. Injected client factory (for composition/integration contexts)
|
||||
2. Explicit routing override (`--local` / `--cloud` or env vars below)
|
||||
3. Project-scoped routing (`project.mode`) when a project is known
|
||||
4. Default local routing
|
||||
|
||||
## Implementation Plan
|
||||
### Routing Environment Variables
|
||||
|
||||
### Step 1: Config model changes
|
||||
- `BASIC_MEMORY_FORCE_LOCAL=true`: force local transport
|
||||
- `BASIC_MEMORY_FORCE_CLOUD=true`: force cloud proxy transport
|
||||
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`: marks routing as explicitly chosen for this command
|
||||
|
||||
**File: `src/basic_memory/config.py`**
|
||||
When explicit routing is active, project mode does not override the selected route.
|
||||
|
||||
- Add `ProjectMode` enum: `LOCAL = "local"`, `CLOUD = "cloud"`
|
||||
- Add `ProjectConfigEntry` Pydantic model: `path: str`, `mode: ProjectMode = LOCAL`
|
||||
- Evolve `BasicMemoryConfig.projects` from `Dict[str, str]` to `Dict[str, ProjectConfigEntry]`
|
||||
- Add `model_validator(mode="before")` to auto-migrate old `{"name": "/path"}` format to `{"name": {"path": "/path", "mode": "local"}}`
|
||||
- Add `cloud_api_key: Optional[str] = None` field to `BasicMemoryConfig` (account-level, not per-project)
|
||||
- Update `ProjectConfig` dataclass to carry `mode` from config entry
|
||||
- Add helpers: `get_project_entry(name)`, `get_project_mode(name)`
|
||||
- Keep global `cloud_mode` as deprecated fallback
|
||||
- Update all code that reads `config.projects` as `Dict[str, str]` to handle `ProjectConfigEntry`
|
||||
## Config Semantics
|
||||
|
||||
### Step 2: Client routing
|
||||
- `project.mode` is the only config-based routing signal for project-scoped operations.
|
||||
- Legacy `cloud_mode` values may be encountered during migration/loading but are not used for routing behavior.
|
||||
- Normalization saves remove stale `cloud_mode` from `~/.basic-memory/config.json`.
|
||||
|
||||
**File: `src/basic_memory/mcp/async_client.py`**
|
||||
|
||||
- Add optional `project_name: Optional[str] = None` parameter to `get_client()`
|
||||
- Routing logic (priority order):
|
||||
1. Factory injection (`_client_factory`) — unchanged
|
||||
2. Force-local (`_force_local_mode()`) — unchanged
|
||||
3. **New**: If `project_name` provided and project's mode is `CLOUD` → HTTP client with `cloud_api_key` as Bearer token, hitting `cloud_host/proxy`
|
||||
4. Global `cloud_mode_enabled` fallback — existing OAuth flow (deprecated)
|
||||
5. Default: local ASGI transport
|
||||
- Error if cloud project but no `cloud_api_key` in config — actionable message pointing to `bm cloud set-key` or `bm cloud create-key`
|
||||
|
||||
### Step 3: Project-aware client helper
|
||||
|
||||
**File: `src/basic_memory/mcp/project_context.py`**
|
||||
|
||||
- Add `get_project_client(project, context)` async context manager
|
||||
- Combines `resolve_project_parameter()` (config-only, no network) + `get_client(project_name=resolved)` + `get_active_project(client, resolved, context)`
|
||||
- Returns `(client, active_project)` tuple
|
||||
- Solves bootstrap problem: resolve project name first, create correct client, then validate
|
||||
|
||||
### Step 4: Simplify ProjectResolver
|
||||
|
||||
**File: `src/basic_memory/project_resolver.py`**
|
||||
|
||||
- Remove global `cloud_mode` parameter — routing mode is orthogonal to project resolution
|
||||
- Resolution becomes purely: constrained env var → explicit param → default project
|
||||
- Update `resolve_project_parameter()` in `project_context.py` to drop `cloud_mode` param
|
||||
|
||||
### Step 5: Update MCP tools
|
||||
|
||||
**Files: `src/basic_memory/mcp/tools/*.py` (~15 files)**
|
||||
|
||||
Mechanical change per tool:
|
||||
```python
|
||||
# Before
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# After
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
```
|
||||
|
||||
Special handling for `recent_activity.py` discovery mode: iterate projects, create per-project client for each.
|
||||
|
||||
### Step 6: Sync coordinator
|
||||
|
||||
**Files: `src/basic_memory/sync/coordinator.py`, `src/basic_memory/mcp/container.py`**
|
||||
|
||||
- Filter file watchers to local-mode projects only
|
||||
- Cloud projects skip sync
|
||||
|
||||
### Step 7: CLI commands
|
||||
|
||||
**File: `src/basic_memory/cli/commands/cloud/core_commands.py`**
|
||||
|
||||
- `bm cloud set-key <api-key>` — saves API key to config.json
|
||||
- `bm cloud create-key <name>` — calls `POST {cloud_host}/api/keys` using existing JWT auth (from `make_api_request`), saves returned key to config. Uses existing `api_client.py:make_api_request()` for the authenticated call.
|
||||
|
||||
**File: `src/basic_memory/cli/commands/project.py`**
|
||||
|
||||
- `bm project set-cloud <name>` — sets project mode to cloud (validates API key exists in config)
|
||||
- `bm project set-local <name>` — reverts project to local mode
|
||||
- Extend `bm project list` / `bm project info` to show mode column
|
||||
|
||||
### Step 8: RuntimeMode simplification
|
||||
|
||||
**File: `src/basic_memory/runtime.py`**
|
||||
|
||||
- `resolve_runtime_mode()` drops `cloud_mode_enabled` parameter
|
||||
- Simplifies to: TEST if test env, otherwise LOCAL
|
||||
- `RuntimeMode.CLOUD` kept for backward compat but not used in global resolution
|
||||
|
||||
### Step 9: Tests
|
||||
|
||||
- Config: migration from old format, round-trip serialization, `get_project_mode()`
|
||||
- `get_client()`: local project → ASGI, cloud project → HTTP+API key, missing key → error
|
||||
- `get_project_client()`: resolve + route combined
|
||||
- MCP tools: representative sample with new helper
|
||||
- Sync: cloud projects skipped, local projects synced
|
||||
- CLI: `set-key`, `create-key`, `set-cloud`, `set-local`
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/basic_memory/config.py` | `ProjectMode`, `ProjectConfigEntry`, migration, `cloud_api_key` field |
|
||||
| `src/basic_memory/mcp/async_client.py` | `get_client(project_name=)` per-project routing |
|
||||
| `src/basic_memory/mcp/project_context.py` | `get_project_client()` helper |
|
||||
| `src/basic_memory/project_resolver.py` | Remove global `cloud_mode` concern |
|
||||
| `src/basic_memory/mcp/tools/*.py` | Mechanical swap to `get_project_client()` |
|
||||
| `src/basic_memory/sync/coordinator.py` | Filter to local-mode projects |
|
||||
| `src/basic_memory/mcp/container.py` | Update should_sync logic |
|
||||
| `src/basic_memory/cli/commands/cloud/core_commands.py` | `set-key`, `create-key` commands |
|
||||
| `src/basic_memory/cli/commands/project.py` | `set-cloud`, `set-local` commands |
|
||||
| `src/basic_memory/runtime.py` | Drop cloud_mode from global resolution |
|
||||
|
||||
## Config Example
|
||||
### Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"personal": {"path": "/Users/me/notes", "mode": "local"},
|
||||
"research": {"path": "/Users/me/research", "mode": "cloud"}
|
||||
"main": {
|
||||
"path": "/Users/me/basic-memory",
|
||||
"mode": "local",
|
||||
"cloud_sync_path": null,
|
||||
"bisync_initialized": false,
|
||||
"last_sync": null
|
||||
},
|
||||
"specs": {
|
||||
"path": "specs",
|
||||
"mode": "cloud",
|
||||
"cloud_sync_path": "/Users/me/dev/specs",
|
||||
"bisync_initialized": true,
|
||||
"last_sync": "2026-02-06T17:36:38.544153"
|
||||
}
|
||||
},
|
||||
"default_project": "main",
|
||||
"cloud_api_key": "bmc_abc123...",
|
||||
"cloud_host": "https://cloud.basicmemory.com",
|
||||
"default_project": "personal"
|
||||
"cloud_host": "https://cloud.basicmemory.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
## Cloud Commands Are Auth-Only
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| No API key + cloud project | `get_client()` raises error: "Run `bm cloud set-key` first" |
|
||||
| Old config format loaded | `model_validator` auto-migrates `Dict[str,str]` to new format |
|
||||
| Default project is cloud | Works — resolver returns name, routing uses API key |
|
||||
| Global `cloud_mode=true` (legacy) | Deprecated fallback still works via OAuth |
|
||||
| Factory-injected client (cloud app) | Factory takes priority, unaffected |
|
||||
| `--local` CLI flag on cloud project | Force-local override still works |
|
||||
`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state.
|
||||
|
||||
## Verification
|
||||
- `bm cloud login`
|
||||
- performs OAuth device flow
|
||||
- stores/refreshes token material
|
||||
- may verify cloud health/subscription
|
||||
- does not change routing defaults
|
||||
- `bm cloud logout`
|
||||
- removes stored OAuth session tokens
|
||||
- does not change routing defaults
|
||||
- `bm cloud status`
|
||||
- reports auth state (API key, OAuth token validity)
|
||||
- runs health checks only when credentials are available
|
||||
|
||||
1. `just fast-check` — lint/format/typecheck + impacted tests
|
||||
2. `just test` — full suite (SQLite + Postgres)
|
||||
3. Manual: `bm cloud set-key bmc_...`, `bm project set-cloud test`, run MCP tools against it
|
||||
4. Manual: verify local projects work unchanged
|
||||
5. Manual: `bm project list` shows mode column
|
||||
## MCP Stdio Local Guarantee
|
||||
|
||||
`bm mcp --transport stdio` always routes locally.
|
||||
|
||||
The command sets explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
|
||||
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud routing for stdio MCP,
|
||||
even if the selected project has `mode: cloud`.
|
||||
|
||||
## Project List UX for Dual Presence
|
||||
|
||||
Projects may exist in both local and cloud. `bm project list` should display that clearly in one row per logical
|
||||
project identity, with explicit source/target signals.
|
||||
|
||||
Recommended display contract:
|
||||
|
||||
1. Keep one row per normalized project name/permalink.
|
||||
2. Show both local and cloud presence as separate columns/indicators.
|
||||
3. Show an explicit `MCP (stdio)` target column that always resolves to `local`.
|
||||
4. Keep CLI route semantics explicit:
|
||||
- no flags: default local for non-project commands
|
||||
- `--cloud`: force cloud
|
||||
- `--local`: force local
|
||||
|
||||
## Project LS Targeting
|
||||
|
||||
`bm project ls` should clearly identify which project instance is being listed.
|
||||
|
||||
Targeting rules:
|
||||
|
||||
1. No routing flags: list local project files.
|
||||
2. `--cloud`: list cloud project files.
|
||||
3. `--local`: list local project files (explicit override).
|
||||
4. Output should label the active target (`LOCAL` or `CLOUD`) in heading or status line.
|
||||
|
||||
## Runtime Mode
|
||||
|
||||
Runtime mode is no longer a cloud/local routing switch for local app flows.
|
||||
|
||||
- `resolve_runtime_mode(is_test_env)` resolves to:
|
||||
- `TEST` when running in test environment
|
||||
- `LOCAL` otherwise
|
||||
- `RuntimeMode.CLOUD` may remain for compatibility with existing tests/call sites but is not selected by normal local
|
||||
runtime resolution.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
1. Loading config with legacy `cloud_mode` succeeds.
|
||||
2. Saving config strips legacy `cloud_mode`.
|
||||
3. `--local/--cloud` always override per-project mode for that command.
|
||||
4. No-project + no-flags commands route local by default.
|
||||
5. `bm cloud login/logout` do not toggle routing behavior.
|
||||
6. `bm mcp` remains local-only in stdio mode.
|
||||
7. `bm project list` communicates dual local/cloud presence without ambiguity.
|
||||
8. `bm project ls` output identifies route target explicitly.
|
||||
|
||||
+100
-66
@@ -5,7 +5,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
|
||||
## Overview
|
||||
|
||||
The cloud CLI enables you to:
|
||||
- **Toggle cloud mode** - All regular `bm` commands work with cloud when enabled
|
||||
- **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
|
||||
@@ -40,7 +40,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
|
||||
1. **Cloud-only** - Project exists on cloud, no local copy
|
||||
2. **Cloud + Local (synced)** - Project has a local working directory that syncs
|
||||
3. **Local-only** - Project exists locally (when cloud mode is disabled)
|
||||
3. **Local-only** - Project exists locally and is not routed to cloud
|
||||
|
||||
**Example:**
|
||||
|
||||
@@ -50,9 +50,9 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
# - work: wants local sync at ~/work-notes
|
||||
# - temp: cloud-only, no local sync needed
|
||||
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add temp # No local sync
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
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
|
||||
@@ -68,9 +68,9 @@ bm project bisync --name work
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable Cloud Mode
|
||||
### 1. Authenticate Cloud Access
|
||||
|
||||
Authenticate and enable cloud mode:
|
||||
Authenticate with cloud:
|
||||
|
||||
```bash
|
||||
bm cloud login
|
||||
@@ -78,11 +78,11 @@ bm cloud login
|
||||
|
||||
**What this does:**
|
||||
1. Opens browser to Basic Memory Cloud authentication page
|
||||
2. Stores authentication token in `~/.basic-memory/auth/token`
|
||||
3. **Enables cloud mode** - all CLI commands now work against cloud
|
||||
4. Validates your subscription status
|
||||
2. Stores authentication tokens in `~/.basic-memory/basic-memory-cloud.json`
|
||||
3. Validates your subscription status
|
||||
4. Leaves routing behavior unchanged (auth only)
|
||||
|
||||
**Result:** All `bm project`, `bm tools` commands now work with cloud.
|
||||
**Result:** Cloud credentials are available for cloud-routed commands.
|
||||
Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% off for 3 months.
|
||||
|
||||
### 2. Set Up Sync
|
||||
@@ -107,10 +107,10 @@ Create projects with optional local sync paths:
|
||||
|
||||
```bash
|
||||
# Create cloud project without local sync
|
||||
bm project add research
|
||||
bm project add research --cloud
|
||||
|
||||
# Create cloud project WITH local sync
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
|
||||
# Or configure sync for existing project
|
||||
bm project sync-setup research ~/Documents/research
|
||||
@@ -120,7 +120,7 @@ bm project sync-setup research ~/Documents/research
|
||||
|
||||
When you add a project with `--local-path`:
|
||||
1. Project created on cloud at `/app/data/research`
|
||||
2. Local path stored in config: `cloud_projects.research.local_path = "~/Documents/research"`
|
||||
2. Local path stored in config for that project (`cloud_sync_path`)
|
||||
3. Local directory created if it doesn't exist
|
||||
4. Bisync state directory created at `~/.basic-memory/bisync-state/research/`
|
||||
|
||||
@@ -182,7 +182,8 @@ bm cloud status
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `Mode: Cloud (enabled)`
|
||||
- `OAuth: token valid` (or missing/expired)
|
||||
- `API Key: configured` (or not set)
|
||||
- `Cloud instance is healthy`
|
||||
- Instructions for project sync commands
|
||||
|
||||
@@ -190,16 +191,16 @@ You should see:
|
||||
|
||||
### Understanding Project Commands
|
||||
|
||||
**Key concept:** When cloud mode is enabled, use regular `bm project` commands (not `bm cloud project`).
|
||||
**Key concept:** Use regular `bm project` commands (not `bm cloud project`).
|
||||
|
||||
```bash
|
||||
# In cloud mode:
|
||||
bm project list # Lists cloud projects
|
||||
bm project add research # Creates cloud project
|
||||
# Local route
|
||||
bm project list --local
|
||||
bm project add research ~/Documents/research
|
||||
|
||||
# In local mode:
|
||||
bm project list # Lists local projects
|
||||
bm project add research ~/Documents/research # Creates local project
|
||||
# Cloud route
|
||||
bm project list --cloud
|
||||
bm project add research --cloud
|
||||
```
|
||||
|
||||
### Creating Projects
|
||||
@@ -207,7 +208,7 @@ bm project add research ~/Documents/research # Creates local project
|
||||
**Use case 1: Cloud-only project (no local sync)**
|
||||
|
||||
```bash
|
||||
bm project add temp-notes
|
||||
bm project add temp-notes --cloud
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -220,7 +221,7 @@ bm project add temp-notes
|
||||
**Use case 2: Cloud project with local sync**
|
||||
|
||||
```bash
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -254,11 +255,35 @@ bm project list
|
||||
```
|
||||
|
||||
**What you see:**
|
||||
- All projects in cloud (when cloud mode enabled)
|
||||
- Local projects always
|
||||
- Cloud projects when credentials are available
|
||||
- Default project marked
|
||||
- Project paths shown
|
||||
- Route-related metadata (for example, local/cloud presence and sync info)
|
||||
|
||||
**Future:** Will show sync status (synced/not synced, last sync time).
|
||||
Example shape (single row for dual-presence projects):
|
||||
|
||||
```text
|
||||
Name Path Local Path Cloud Path CLI Default MCP (stdio)
|
||||
main /basic-memory ~/basic-memory /basic-memory local local
|
||||
specs /specs ~/dev/specs /specs cloud local
|
||||
```
|
||||
|
||||
### When a Project Exists in Both Local and Cloud
|
||||
|
||||
Use routing flags to disambiguate command targets:
|
||||
|
||||
```bash
|
||||
# Force local target for this command
|
||||
bm project info main --local
|
||||
bm project ls --name main --local
|
||||
|
||||
# Force cloud target for this command
|
||||
bm project info main --cloud
|
||||
bm project ls --name main --cloud
|
||||
```
|
||||
|
||||
Default behavior for no-project, no-flag commands is local.
|
||||
For MCP stdio, routing is always local.
|
||||
|
||||
## File Synchronization
|
||||
|
||||
@@ -366,24 +391,28 @@ bm project bisync --name research --dry-run
|
||||
|
||||
**Result:** Safe preview of sync operations.
|
||||
|
||||
### Advanced: List Remote Files
|
||||
### Advanced: List Project Files by Route
|
||||
|
||||
**Use case:** See what files exist on cloud without syncing.
|
||||
**Use case:** Inspect local or cloud project files explicitly.
|
||||
|
||||
```bash
|
||||
# List all files in project
|
||||
# List local project files (default target when no route flag is given)
|
||||
bm project ls --name research
|
||||
bm project ls --name research --local
|
||||
|
||||
# List cloud project files
|
||||
bm project ls --name research --cloud
|
||||
|
||||
# List files in subdirectory
|
||||
bm project ls --name research --path subfolder
|
||||
bm project ls --name research --cloud --path subfolder
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Connects to cloud via rclone
|
||||
2. Lists files in remote project path
|
||||
1. Resolves route from flags (or local default when no route is given)
|
||||
2. Lists files for the chosen project instance
|
||||
3. No files transferred
|
||||
|
||||
**Result:** See cloud file listing.
|
||||
**Result:** See file listing for the target route.
|
||||
|
||||
## Multiple Projects
|
||||
|
||||
@@ -393,9 +422,9 @@ bm project ls --name research --path subfolder
|
||||
|
||||
```bash
|
||||
# Setup multiple projects
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add personal --local-path ~/personal
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
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
|
||||
@@ -420,12 +449,12 @@ bm project bisync --all # Coming soon
|
||||
|
||||
```bash
|
||||
# Projects with sync
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work
|
||||
|
||||
# Cloud-only projects
|
||||
bm project add archive
|
||||
bm project add temp-notes
|
||||
bm project add archive --cloud
|
||||
bm project add temp-notes --cloud
|
||||
|
||||
# Sync only the configured ones
|
||||
bm project bisync --name research
|
||||
@@ -438,7 +467,7 @@ bm project bisync --name work
|
||||
|
||||
## Per-Project Cloud Routing (API Key)
|
||||
|
||||
Instead of toggling global cloud mode, you can route individual projects through the cloud using an API key. This lets you keep some projects local while others route through the cloud.
|
||||
Route individual projects through cloud using an API key. This lets you keep some projects local while others route through cloud.
|
||||
|
||||
### Setting Up API Key Auth
|
||||
|
||||
@@ -485,10 +514,16 @@ When an MCP tool or CLI command runs for a cloud-mode project:
|
||||
|
||||
**Routing priority** (highest to lowest):
|
||||
1. Factory injection (cloud app, tests)
|
||||
2. `BASIC_MEMORY_FORCE_LOCAL` env var
|
||||
2. Explicit route override (`--local` / `--cloud`)
|
||||
3. Per-project cloud mode (API key)
|
||||
4. Global cloud mode (OAuth — deprecated fallback)
|
||||
5. Local ASGI transport (default)
|
||||
4. Local ASGI transport (default)
|
||||
|
||||
Route override environment variables:
|
||||
- `BASIC_MEMORY_FORCE_LOCAL=true`
|
||||
- `BASIC_MEMORY_FORCE_CLOUD=true`
|
||||
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`
|
||||
|
||||
No-project, no-flag CLI commands default to local routing.
|
||||
|
||||
### Configuration Example
|
||||
|
||||
@@ -513,20 +548,18 @@ In this example, `personal` stays local and `research` routes through cloud. Pro
|
||||
|
||||
Cloud-mode projects are automatically skipped during local file sync (background sync and file watching). Their files live on the cloud instance, not locally.
|
||||
|
||||
## Disable Cloud Mode
|
||||
|
||||
Return to local mode (global):
|
||||
## OAuth Logout
|
||||
|
||||
```bash
|
||||
bm cloud logout
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
1. Disables global cloud mode in config
|
||||
2. All commands now work locally (unless individual projects are set to cloud via `set-cloud`)
|
||||
3. Auth token remains (can re-enable with login)
|
||||
1. Removes stored OAuth token(s)
|
||||
2. Does not change per-project route configuration
|
||||
3. Does not change command routing defaults
|
||||
|
||||
**Result:** All `bm` commands work with local projects again. Per-project cloud routing via API key continues to work independently of global cloud mode.
|
||||
**Result:** OAuth session is cleared. API-key-based routing still works if `cloud_api_key` is configured.
|
||||
|
||||
## Filter Configuration
|
||||
|
||||
@@ -733,12 +766,12 @@ If instance is down, wait a few minutes and retry.
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Cloud Mode Management
|
||||
### Cloud Authentication
|
||||
|
||||
```bash
|
||||
bm cloud login # Authenticate and enable global cloud mode (OAuth)
|
||||
bm cloud logout # Disable global cloud mode
|
||||
bm cloud status # Check cloud mode and instance health
|
||||
bm cloud login # Authenticate and store OAuth credentials
|
||||
bm cloud logout # Remove stored OAuth credentials
|
||||
bm cloud status # Check auth state and instance health
|
||||
bm cloud promo --off # Disable CLI cloud promo notices
|
||||
```
|
||||
|
||||
@@ -757,12 +790,11 @@ bm cloud setup # Install rclone and configure credentials
|
||||
|
||||
### Project Management
|
||||
|
||||
When cloud mode is enabled:
|
||||
|
||||
```bash
|
||||
bm project list # List projects with mode column
|
||||
bm project add <name> # Create cloud project (no sync)
|
||||
bm project add <name> --local-path <path> # Create with local sync
|
||||
bm project list --local # Local project list
|
||||
bm project list --cloud # Cloud project list
|
||||
bm project add <name> --cloud # Create cloud project (no sync)
|
||||
bm project add <name> --cloud --local-path <path> # Create with local sync
|
||||
bm project sync-setup <name> <path> # Add sync to existing project
|
||||
bm project rm <name> # Delete project
|
||||
```
|
||||
@@ -792,18 +824,20 @@ bm project bisync --name <project> --verbose
|
||||
bm project check --name <project>
|
||||
bm project check --name <project> --one-way
|
||||
|
||||
# List remote files
|
||||
bm project ls --name <project>
|
||||
bm project ls --name <project> --path <subpath>
|
||||
# List project files by route
|
||||
bm project ls --name <project> # Default target: local
|
||||
bm project ls --name <project> --local
|
||||
bm project ls --name <project> --cloud
|
||||
bm project ls --name <project> --cloud --path <subpath>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
**Basic Memory Cloud uses project-scoped sync:**
|
||||
|
||||
1. **Enable cloud mode** - `bm cloud login`
|
||||
1. **Authenticate cloud access** - `bm cloud login`
|
||||
2. **Install rclone** - `bm cloud setup`
|
||||
3. **Add projects with sync** - `bm project add research --local-path ~/Documents/research`
|
||||
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`
|
||||
|
||||
@@ -83,18 +83,26 @@ Manual check:
|
||||
|
||||
---
|
||||
|
||||
### 2) ASCII / ANSI TUI Output
|
||||
### 2) Text / JSON Output Modes
|
||||
|
||||
Tools:
|
||||
- `search_notes(output_format="ascii" | "ansi")`
|
||||
- `read_note(output_format="ascii" | "ansi")`
|
||||
- `search_notes(output_format="text" | "json")`
|
||||
- `read_note(output_format="text" | "json")`
|
||||
- `write_note(output_format="text" | "json")`
|
||||
- `edit_note(output_format="text" | "json")`
|
||||
- `recent_activity(output_format="text" | "json")`
|
||||
- `list_memory_projects(output_format="text" | "json")`
|
||||
- `create_memory_project(output_format="text" | "json")`
|
||||
- `delete_note(output_format="text" | "json")`
|
||||
- `move_note(output_format="text" | "json")`
|
||||
- `build_context(output_format="json" | "text")`
|
||||
|
||||
Expect:
|
||||
- ASCII table for search, header + content preview for note.
|
||||
- ANSI variants include color escape codes.
|
||||
- `text` mode preserves existing human-readable responses.
|
||||
- `json` mode returns structured dict/list payloads for machine-readable clients.
|
||||
|
||||
Automated:
|
||||
- `uv run pytest test-int/mcp/test_output_format_ascii_integration.py`
|
||||
- `uv run pytest test-int/mcp/test_output_format_json_integration.py`
|
||||
|
||||
---
|
||||
|
||||
@@ -125,6 +133,6 @@ Fill in after running:
|
||||
|
||||
- Tool‑UI (React): __
|
||||
- MCP‑UI SDK (embedded): __
|
||||
- ASCII/ANSI: __
|
||||
- Text/JSON modes: __
|
||||
|
||||
Decision + rationale: __
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# Post-v0.18.0 Test Plan and Acceptance Criteria
|
||||
|
||||
## Goal
|
||||
|
||||
Define a complete validation plan for all major features merged after `v0.18.0`, combining:
|
||||
|
||||
- Coverage-gap-driven automated tests
|
||||
- Real MCP server integration tests (no mocks for target flows)
|
||||
- Manual MCP verification via LLM-driven tool calls
|
||||
|
||||
This plan is based on commits in `v0.18.0..HEAD` and the latest `just check` coverage output.
|
||||
|
||||
## Scope Window
|
||||
|
||||
- Start tag: `v0.18.0` (2026-01-28)
|
||||
- End: current `main`
|
||||
- Change volume: 12 feature commits + 14 bug-fix commits (+ release chores/hotfixes)
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
1. Stabilize all feature-level acceptance criteria in automated tests first.
|
||||
2. Add black-box MCP integration tests for semantic search + schema (real server startup).
|
||||
3. Run manual MCP tool-call verification to confirm real UX and routing behavior.
|
||||
4. Re-run full gate: `just check` + targeted integration packs.
|
||||
|
||||
## Global Quality Gates
|
||||
|
||||
- Feature criteria below must all pass.
|
||||
- No regressions in existing suites.
|
||||
- Coverage improves in targeted low-coverage feature modules.
|
||||
- SQLite and Postgres parity for search/semantic features.
|
||||
|
||||
## Priority Coverage Gaps (from latest run)
|
||||
|
||||
These are the most important post-`v0.18.0` feature modules currently under-covered:
|
||||
|
||||
- `src/basic_memory/mcp/tools/schema.py` (27%)
|
||||
- `src/basic_memory/mcp/clients/schema.py` (36%)
|
||||
- `src/basic_memory/mcp/tools/ui_sdk.py` (43%)
|
||||
- `src/basic_memory/mcp/tools/search.py` (73%)
|
||||
- `src/basic_memory/repository/postgres_search_repository.py` (63%)
|
||||
- `src/basic_memory/mcp/async_client.py` (82%)
|
||||
- `src/basic_memory/api/v2/routers/schema_router.py` (80%)
|
||||
|
||||
## Feature Acceptance Criteria and Test Plan
|
||||
|
||||
### 1) Schema System (`c97733d`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `schema_validate`, `schema_infer`, and `schema_diff` produce consistent outcomes across CLI/API/MCP for the same fixture set.
|
||||
- Strict validation fails deterministically on required-field/type violations.
|
||||
- Validation warnings are stable and machine-readable in non-strict mode.
|
||||
- Inference output is deterministic for unchanged input corpus.
|
||||
- Drift diff output is deterministic and identifies missing/extra/type-mismatch fields correctly.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/schema/*`
|
||||
- `tests/api/v2/test_schema_router.py`
|
||||
- `test-int/test_schema/*`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~MCP schema tool branches (`src/basic_memory/mcp/tools/schema.py`)~~ — 18 tests in `tests/mcp/test_tool_schema.py`
|
||||
- ~~MCP schema client behavior (`src/basic_memory/mcp/clients/schema.py`)~~ — `tests/mcp/test_client_schema.py`
|
||||
- ~~Schema router error-path branches (`src/basic_memory/api/v2/routers/schema_router.py`)~~ — `tests/api/v2/test_schema_router.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add MCP tool tests for `schema_validate` strict + non-strict result shapes.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_infer` with explicit `entity_type` and inferred type fallback.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_diff` empty-diff and non-empty-diff paths.~~ **DONE**
|
||||
- ~~Add API tests for schema router invalid payload/edge error handling.~~ **DONE**
|
||||
- Add integration test that starts MCP server and calls schema tools end-to-end on fixture notes. — deferred to backlog item 4.
|
||||
|
||||
### 2) Semantic Search (`0777879`, `1428d18`, `344e651`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `search_type=text|vector|hybrid` returns expected ranked results on canonical semantic corpus.
|
||||
- Missing semantic extras fail fast with actionable install guidance.
|
||||
- Reindex and provider/model changes produce valid vectors without dimension mismatch.
|
||||
- SQLite and Postgres produce equivalent behavior for semantic modes on the same dataset.
|
||||
- Generated-column migration path is valid on SQLite environments in use.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_sqlite_vector_search_repository.py`
|
||||
- `tests/repository/test_postgres_search_repository.py`
|
||||
- `tests/services/test_semantic_search.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `test-int/test_search_performance_benchmark.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Uncovered Postgres vector/hybrid branches~~ — 20 tests in `tests/repository/test_postgres_search_repository_unit.py` + 5 integration tests in `test-int/semantic/test_semantic_coverage.py`
|
||||
- ~~MCP search semantic/output branches~~ — expanded `tests/mcp/test_tool_search.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Expand Postgres repository tests for vector query composition edge cases.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for hybrid fusion ranking and pagination branches.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for embedding/provider error handling branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for vector/hybrid output formatting branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for semantic-disabled and missing-dependency failures.~~ **DONE**
|
||||
- Add MCP integration tests that start server and execute semantic `search_notes` tool calls. — deferred to backlog item 4.
|
||||
|
||||
### Semantic search quality benchmarks (NEW)
|
||||
|
||||
Full benchmark suite in `test-int/semantic/` covering 5 backend×provider combinations:
|
||||
- `sqlite-fts`, `sqlite-fastembed`, `postgres-fts`, `postgres-fastembed`, `postgres-openai`
|
||||
- Quality metrics: hit@1, recall@5, MRR@10 with per-query timing
|
||||
- Realistic corpus with cross-topic vocabulary overlap (240 notes, 4 topics)
|
||||
- Rich CLI viewer: `just semantic-report`
|
||||
- JSON artifact output: `just test-semantic-report`
|
||||
|
||||
Key finding: **FastEmbed (384-d local ONNX) matches or exceeds OpenAI (1536-d) quality at 30x lower latency.** Recommending FastEmbed as default for both local and cloud deployments.
|
||||
|
||||
### 3) Per-Project Local/Cloud Routing + API Key Auth (`d84708c`, `ed94877`, `312662f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project mode (`local`/`cloud`) persists and displays correctly.
|
||||
- Routing selects ASGI for local projects and HTTP+Bearer for cloud projects.
|
||||
- Cloud project without key fails with explicit remediation (`cloud set-key`/`cloud create-key`).
|
||||
- Resolution precedence is correct (factory > force-local > per-project cloud > global fallback > local).
|
||||
- Watch/sync only run for local projects.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_async_client_modes.py`
|
||||
- `tests/cli/test_project_set_cloud_local.py`
|
||||
- `tests/mcp/test_project_context.py`
|
||||
- `tests/test_project_resolver.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Cloud routing branch gaps in `src/basic_memory/mcp/async_client.py`~~ — expanded `tests/mcp/test_async_client_modes.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add branch-focused tests for all unresolved routing branches in `get_client()`.~~ **DONE**
|
||||
- Add MCP integration scenario with mixed local/cloud project config — deferred to backlog item 4.
|
||||
|
||||
### 4) Project-Prefixed Permalinks + Memory URL Routing (`545804f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project-prefixed permalinks are generated consistently on create/update/import flows.
|
||||
- Memory URLs resolve to the correct project/entity even with duplicate note titles.
|
||||
- `read_note`, `search`, `build_context`, write/edit/move flows preserve project identity correctly.
|
||||
- Link resolution remains correct for context-aware wikilinks.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/utils/test_permalink_formatting.py`
|
||||
- `tests/mcp/test_tool_read_note.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `tests/services/test_context_service.py`
|
||||
- `test-int/mcp/test_read_note_integration.py`
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- No major coverage alarm in report, but keep as regression-critical due broad impact surface.
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one integration test with colliding titles across two projects and assert URL routing invariants.~~ **DONE** — `test-int/mcp/test_permalink_collision_integration.py` (2 tests: collision across projects + memory:// URL routing with project prefix)
|
||||
|
||||
### 5) MCP UI Variants + TUI Output (`8bc03d1`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- UI resource variant selection (`tool-ui`, `vanilla`, `mcp-ui`) follows env configuration.
|
||||
- `search_notes` and `read_note` expose expected resource metadata for UI hosts.
|
||||
- `ascii`/`ansi` outputs are deterministic and stable for terminal clients.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_tool_contracts.py`
|
||||
- `test-int/mcp/test_output_format_json_integration.py`
|
||||
- `test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~`src/basic_memory/mcp/tools/ui_sdk.py` branch coverage~~ — `tests/mcp/test_ui_sdk.py`
|
||||
- ~~`src/basic_memory/mcp/ui/sdk.py` and `src/basic_memory/mcp/ui/templates.py` branch coverage~~ — `tests/mcp/test_ui_templates.py` + `tests/mcp/test_ui_resources.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add unit tests for UI SDK metadata generation and template selection branches.~~ **DONE** — 31 tests
|
||||
- ~~Add integration assertion for variant-specific resource URIs and metadata payload shape.~~ **DONE**
|
||||
|
||||
### 6) Watch Command (`8df88e4`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `basic-memory watch` starts and processes create/update/delete events.
|
||||
- Watch restart/reload path does not duplicate watchers.
|
||||
- Cloud-mode projects are excluded from active watcher set.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_watch.py`
|
||||
- `tests/sync/test_coordinator.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one stress-style integration test for rapid file changes and watcher stability.~~ **DONE** — `tests/sync/test_watch_service_stress.py` (3 tests: 50-file batch, mixed add/modify/delete batch, rapid modifications to same file)
|
||||
|
||||
### 7) CLI JSON Output (`a47c9c0`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `--format json` returns valid JSON with stable keys for success paths.
|
||||
- Error paths also return JSON-shaped output with correct non-zero exits.
|
||||
- Default human output remains unchanged.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_cli_tool_json_output.py`
|
||||
- `test-int/cli/test_cli_tool_json_integration.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one failure-path integration test per high-use tool command.~~ **DONE** — `test-int/cli/test_cli_tool_json_failure_integration.py` (4 tests: read-note not found, write-note missing content, write→read roundtrip, recent-activity empty project)
|
||||
|
||||
### 8) Search/Edit and Metadata Fixes (`530cbac`, `f1d50c2`, `8838571`, `009e849`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Metadata filters produce consistent results on SQLite and Postgres.
|
||||
- `tag:` shorthand works alone and with mixed query terms.
|
||||
- Fast write/edit paths preserve `external_id` and metadata integrity.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_metadata_filters.py`
|
||||
- `tests/repository/test_search_repository.py`
|
||||
- `tests/services/test_search_service.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add Postgres-specific metadata filter edge-case tests to mirror SQLite assertions exactly.~~ **DONE** — `tests/repository/test_metadata_filters_edge_cases.py` (6 tests: missing field, AND logic, contains single-element array, nested path missing intermediate, $gte/$lte boundaries, $between inclusive — all pass on both SQLite and Postgres)
|
||||
|
||||
### 9) Compatibility and Hotfix Regression Pack (`c46d7a6`, `a0e754b`, `343a6e1`, `24ca5f6`, `e3ced49`, `8489a3d`, `b609c4e`, `f6e0a5b`, `7624a20`)
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Legacy endpoints required by older CLI versions function without `405` (`GET /projects/projects`, `POST /projects/projects`, `POST /projects/config/sync`).
|
||||
- Entity creation conflicts map to conflict status (not 500).
|
||||
- `recent_activity` prompt defaults are correct.
|
||||
- No spurious `metadata: {}` in serialized frontmatter.
|
||||
- Tigris/rclone uses global consistency headers for all transaction types.
|
||||
- `bm --version` fast path avoids heavy import path and remains responsive.
|
||||
- Default SQLite DB path is isolated by config dir.
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- ~~Commits with no direct tests added (`c46d7a6`, `344e651`, `f6e0a5b`) need explicit regression tests.~~ **DONE**
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add API compat test covering all legacy endpoint methods and payloads.~~ **DONE** — `test_legacy_v1_add_project_endpoint`, `test_legacy_v1_sync_config_endpoint`
|
||||
- ~~Add CLI fast-path test for `--version` import behavior/performance guard.~~ **DONE** — `test_bm_version_does_not_import_heavy_modules`
|
||||
- ~~Add empty metadata serialization regression test.~~ **DONE** — `test_schema_to_markdown_empty_metadata_no_metadata_key`
|
||||
- Add migration safety test for SQLite generated columns (`VIRTUAL` expectation) — deferred, low risk.
|
||||
|
||||
## MCP Manual Verification Plan (LLM Tool Calls)
|
||||
|
||||
Run after automated tests pass.
|
||||
|
||||
### Setup
|
||||
|
||||
- Start MCP server: `basic-memory mcp --transport stdio`
|
||||
- Use an MCP-capable client and issue tool calls directly.
|
||||
|
||||
### Manual scenarios
|
||||
|
||||
- Schema: call `schema_validate`, `schema_infer`, and `schema_diff` on known fixtures.
|
||||
- Schema: verify error and success payloads match acceptance criteria.
|
||||
- Semantic search: call `search_notes` with `search_type=text|vector|hybrid`.
|
||||
- Semantic search: verify ranking relevance on semantic fixture queries.
|
||||
- Routing: call tools with explicit project on mixed local/cloud setup.
|
||||
- Routing: verify success/failure paths with and without API key.
|
||||
- Permalink routing: read/write/search notes across projects with colliding titles.
|
||||
- Permalink routing: verify memory URL routing correctness.
|
||||
- UI/TUI: call `search_notes` and `read_note` with UI variants and `output_format=text|json`.
|
||||
- UI/TUI: verify payload/resource format and metadata completeness.
|
||||
|
||||
## Implementation Backlog (Ordered)
|
||||
|
||||
1. ~~Fill schema MCP/client/router coverage gaps.~~ **DONE** — 18 tests in `test_tool_schema.py` + `test_client_schema.py`
|
||||
2. ~~Fill semantic search MCP + Postgres repository gaps.~~ **DONE** — 20 tests in `test_postgres_search_repository_unit.py` + `test_tool_search.py`
|
||||
3. ~~Add compatibility regression tests (legacy endpoints, migration, version fast path).~~ **DONE** — 5 tests across 3 files (see below)
|
||||
4. ~~Add feature-level integration tests (permalinks, watch, CLI JSON, metadata filters).~~ **DONE** — 15 tests across 4 files (see items 4, 6, 7, 8 above)
|
||||
5. ~~Expand UI SDK and template branch tests.~~ **DONE** — 31 tests in `test_ui_templates.py` + `test_ui_sdk.py` + `test_ui_resources.py`
|
||||
6. ~~Run full gate and capture results in a short release readiness summary.~~ **DONE** — see results below
|
||||
|
||||
### Full Gate Results (`just check`)
|
||||
|
||||
| Phase | Result |
|
||||
|-------|--------|
|
||||
| lint | PASS |
|
||||
| format | PASS |
|
||||
| typecheck | PASS |
|
||||
| Unit tests (SQLite) | 1788 passed, 15 skipped |
|
||||
| Integration tests (SQLite) | 243 passed, 4 skipped, 10 deselected |
|
||||
| Unit tests (Postgres) | 1760 passed, 28 skipped |
|
||||
| Integration tests (Postgres) | 234 passed, 13 skipped, 10 deselected |
|
||||
|
||||
**0 failures. 10 deselected = semantic benchmark tests (run separately via `just test-semantic`).**
|
||||
|
||||
### Item 3 Details — Compatibility Regression Tests
|
||||
|
||||
| Test | File | What it covers |
|
||||
|------|------|----------------|
|
||||
| `test_legacy_v1_add_project_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/projects` legacy route reachable (idempotent path) |
|
||||
| `test_legacy_v1_sync_config_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/config/sync` legacy route reachable |
|
||||
| `test_bm_version_does_not_import_heavy_modules` | `tests/cli/test_cli_exit.py` | `bm --version` fast path does not load `basic_memory.mcp` |
|
||||
| `test_schema_to_markdown_empty_metadata_no_metadata_key` | `tests/markdown/test_entity_parser_error_handling.py` | `schema_to_markdown()` with `entity_metadata={}` emits no `metadata:` key |
|
||||
| `test_legacy_v1_list_projects_endpoint` | `tests/api/v2/test_project_router.py` | (pre-existing) GET `/projects/projects` legacy route |
|
||||
|
||||
**Suite totals after item 3: 1764 passed, 15 skipped, 0 failures.**
|
||||
|
||||
## Suggested Commands
|
||||
|
||||
- Full suite: `just check`
|
||||
- Fast loop: `just fast-check`
|
||||
- E2E consistency: `just doctor`
|
||||
- SQLite focused: `just test-sqlite`
|
||||
- Postgres focused: `just test-postgres`
|
||||
- Schema integration: `pytest test-int/test_schema -q`
|
||||
- Semantic + repo focus: `pytest tests/repository/test_postgres_search_repository.py tests/mcp/test_tool_search.py tests/services/test_semantic_search.py -q`
|
||||
- MCP integration focus: `pytest test-int/mcp -q`
|
||||
|
||||
## Exit Criteria for This Plan
|
||||
|
||||
- All feature acceptance criteria above are validated.
|
||||
- All identified high-priority coverage gaps are addressed or explicitly documented as intentional.
|
||||
- Manual MCP verification scenarios complete with no P0/P1 findings.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Semantic Search Manual Test Log
|
||||
|
||||
## Overview
|
||||
|
||||
Manual test session for semantic (vector) search on the main project.
|
||||
- Date: 2026-02-15
|
||||
- Database: ~/.basic-memory/memory.db (SQLite)
|
||||
- Entities: 456 embedded, 2714 vector chunks
|
||||
- Search index: 2390 FTS entries
|
||||
- Embedding model: default (384-dim, sqlite-vec)
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. **Search Type Routing** — verify vector/hybrid/text dispatch, invalid search_type handling
|
||||
2. **Conceptual Queries** — natural language where vector should beat FTS
|
||||
3. **Keyword Queries** — exact terms where FTS should be strong
|
||||
4. **Hybrid Ranking** — queries where both FTS and vector contribute
|
||||
5. **Result Types** — entities, observations, relations in vector results
|
||||
6. **Filters + Vector** — combine vector with types/entity_types/after_date
|
||||
7. **Edge Cases** — short queries, long queries, empty, special chars, no-match
|
||||
8. **Pagination** — page > 1, page_size respected
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
### Test 1: Search Type Routing
|
||||
|
||||
#### 1a: search_type="semantic" (invalid value)
|
||||
- **Input:** query="how does the knowledge graph work", search_type="semantic"
|
||||
- **Expected:** error or explicit fallback
|
||||
- **Actual:** Silently falls through to text search (else branch in search.py:430)
|
||||
- **Verdict:** BUG — should either be a recognized alias for "vector" or return an error
|
||||
|
||||
#### 1b: search_type="vector"
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Actual:** 5 results, scores ~0.58-0.59, found "Maintaining context across conversation boundaries" observation
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1c: search_type="text" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="text"
|
||||
- **Actual:** 0 results (no exact keyword match)
|
||||
- **Verdict:** PASS (expected — FTS requires token overlap)
|
||||
|
||||
#### 1d: search_type="hybrid" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="hybrid"
|
||||
- **Actual:** 5 results, same ranking as vector (FTS contributed nothing here)
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1e: search_type="text" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="text"
|
||||
- **Actual:** 3 results — AUTH.md Supabase OAuth, OAuth Rip-and-Replace, OAuth Integration Analysis
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1f: search_type="vector" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="vector"
|
||||
- **Actual:** Same top results as text (keyword-rich content also scores well in vector space)
|
||||
- **Verdict:** PASS
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Conceptual Queries (vector advantage)
|
||||
|
||||
#### 2a: Natural language question
|
||||
- **Input:** query="why do AI assistants forget things", search_type="vector"
|
||||
- **Actual:** 5 results — Manual Testing Session, "Balance security and usability" observation, "Tools should match thought patterns" observation. Scores ~0.56-0.57
|
||||
- **Vector advantage:** Found conceptually related content despite no exact keyword overlap
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 2b: Same query, text search
|
||||
- **Input:** query="why do AI assistants forget things", search_type="text"
|
||||
- **Actual:** 1 result — "What is Basic Memory?" (likely matched on "AI" token)
|
||||
- **Verdict:** PASS (demonstrates vector advantage — text barely matched)
|
||||
|
||||
#### 2c: Domain concept with no jargon
|
||||
- **Input:** query="pricing strategy for cloud product", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-16 MCP Cloud Service Consolidation, knowledge architecture observation, Visual Knowledge Spaces relation. Scores ~0.56-0.57
|
||||
- **Verdict:** PASS (found cloud-related content conceptually)
|
||||
|
||||
#### 2d: Technical concept, long query
|
||||
- **Input:** query="SQLite performance optimization WAL mode concurrent writes", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-11 API Performance Optimization, Real-Time Updates with WebSockets, marketing status update. Scores ~0.55-0.58
|
||||
- **Verdict:** PASS (found performance-related content)
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Keyword Queries (FTS strength)
|
||||
|
||||
#### 3a: Exact term match — "OAuth authentication"
|
||||
- **Text:** 3 results with high relevance (exact matches in titles)
|
||||
- **Vector:** Same top results (keyword overlap helps vector too)
|
||||
- **Verdict:** PASS — FTS and vector converge on keyword-rich queries
|
||||
|
||||
#### 3b: "OAuth" single keyword, hybrid mode
|
||||
- **Input:** query="OAuth", search_type="hybrid"
|
||||
- **Actual:** 5 results — Basic Memory Coding Guide, AI Collaboration Examples, SPEC-18, daily note, Manual Testing Session. FTS + vector blended. Scores ~0.016-0.032
|
||||
- **Note:** Top hybrid result is "Basic Memory Coding Guide" not an OAuth-specific doc — suggests hybrid scoring may dilute strong FTS matches
|
||||
- **Verdict:** PASS but hybrid ranking questionable for single-keyword queries
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Hybrid Ranking
|
||||
|
||||
#### 4a: Hybrid vs vector on "OAuth authentication"
|
||||
- **Hybrid with entity_types=["entity"]:** 5 results — RLS Implementation Lessons, Cloud Readiness Assessment, AUTH.md OAuth, Core Service Implementation, OAuth Rip-and-Replace. Scores ~0.016-0.023
|
||||
- **Vector with entity_types=["entity"]:** 5 results — Core Service Implementation, SPEC-13 CLI Auth, Coding Guide, Authentication Service, ADR Production Auth. Scores ~0.55-0.60
|
||||
- **Observation:** Hybrid surfaces different top results than vector-only. Hybrid found RLS and Cloud Readiness docs that vector didn't prioritize. Different ranking is expected from RRF fusion.
|
||||
- **Verdict:** PASS — hybrid produces meaningfully different ranking
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Result Types
|
||||
|
||||
#### 5a: Vector returns all result types
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Entities:** SPEC-18 AI Memory Management Tool (type=entity)
|
||||
- **Relations:** Prompt Builder integrates_with (type=relation)
|
||||
- **Observations:** "Translation layer is key" (type=observation), "Maintaining context across conversation boundaries" (type=observation)
|
||||
- **Verdict:** PASS — all three types appear in vector results
|
||||
|
||||
#### 5b: Observations carry metadata
|
||||
- **Observation result:** category="challenge", content="Maintaining context across conversation boundaries", from_entity="research/ai-knowledge-management-research"
|
||||
- **Verdict:** PASS — category, content, from_entity, tags all present
|
||||
|
||||
#### 5c: Relations carry link info
|
||||
- **Relation result:** relation_type="integrates_with", from_entity="development/features/prompt-builder...", to_entity (present but truncated in some)
|
||||
- **Verdict:** PASS — relation metadata present
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Filters + Vector Search
|
||||
|
||||
#### 6a: entity_types=["entity"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" (Core Service Implementation, SPEC-13, Coding Guide, Authentication Service, ADR Auth)
|
||||
- **Verdict:** PASS — filter correctly restricts to entities only
|
||||
|
||||
#### 6b: types=["note"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["note"]
|
||||
- **Actual:** Same 5 results (all have entity_type="note" in metadata)
|
||||
- **Verdict:** PASS — types filter works with vector search
|
||||
|
||||
#### 6c: after_date with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", after_date="2025-06-01"
|
||||
- **Actual:** 3 results — Core Service Implementation, Cloud Web App analysis observation, SPEC-13. Filtered out older OAuth docs.
|
||||
- **Verdict:** PASS — date filter applied correctly
|
||||
|
||||
#### 6d: entity_types=["entity"] with hybrid
|
||||
- **Input:** query="OAuth authentication", search_type="hybrid", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" — RLS lessons, Cloud Readiness, AUTH.md OAuth, Core Service, OAuth Rip-and-Replace
|
||||
- **Verdict:** PASS — filter works with hybrid mode too
|
||||
|
||||
#### 6e: types=["entity"] with vector (WRONG filter name)
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["entity"]
|
||||
- **Actual:** 0 results
|
||||
- **Note:** `types` filters by entity_type metadata (e.g., "note", "person"), NOT by SearchItemType. Using types=["entity"] looks for entity_type="entity" which few/no notes have. This is a UX confusion point — the param names are ambiguous.
|
||||
- **Verdict:** PASS (correct behavior) but USABILITY ISSUE — easy to confuse types vs entity_types
|
||||
|
||||
---
|
||||
|
||||
### Test 7: Edge Cases
|
||||
|
||||
#### 7a: Single character query
|
||||
- **Input:** query="x", search_type="vector"
|
||||
- **Actual:** 3 results — "Self-contained application bundle" observation, Non-Markdown File Support relation, quick-win-tools entity. Scores ~0.57-0.59
|
||||
- **Note:** Single character still produces an embedding and returns results. Quality is low/random as expected.
|
||||
- **Verdict:** PASS (no crash, returns results)
|
||||
|
||||
#### 7b: Whitespace-only query
|
||||
- **Input:** query=" ", search_type="vector"
|
||||
- **Actual:** 0 results
|
||||
- **Verdict:** PASS (handled gracefully — _check_vector_eligible strips and rejects empty)
|
||||
|
||||
#### 7c: Query with no relevant content
|
||||
- **Input:** query="quantum computing blockchain", search_type="vector"
|
||||
- **Actual:** 3 results — Inter-Agent Communication relation, Self-contained bundle observation, JSON-LD interop observation. Scores ~0.54
|
||||
- **Note:** Still returns results because vector search always finds nearest neighbors. Scores are lower (~0.54) than relevant queries (~0.58-0.60). No relevance threshold applied.
|
||||
- **Verdict:** PASS (expected behavior) but NOTE — no relevance cutoff means irrelevant queries always return something
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Pagination
|
||||
|
||||
#### 8a: Vector search page 2
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector", page=2, page_size=3
|
||||
- **Actual:** 3 results on page 2, current_page=2. Different results from page 1. Top: "Maintaining context across conversation boundaries" observation (score 0.587)
|
||||
- **Note:** Interestingly, page 2 had a higher-scoring result than some page 1 results. This may indicate pagination doesn't sort globally — it might be paginating within a pre-scored set.
|
||||
- **Verdict:** PASS (pagination works) but POSSIBLE ISSUE — result ordering across pages needs investigation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Passing Tests: 20/21
|
||||
|
||||
### Bugs Found
|
||||
1. **search_type="semantic" silently falls through** (Test 1a) — Invalid search_type values fall to the `else` branch and default to text search without any warning. Should either alias "semantic" to "vector" or raise an error.
|
||||
|
||||
### Usability Issues
|
||||
2. **types vs entity_types confusion** (Test 6e) — `types` filters by entity_type metadata (note, person, etc.) while `entity_types` filters by SearchItemType (entity, observation, relation). The naming is ambiguous and easy to mix up.
|
||||
3. **No relevance threshold** (Test 7c) — Vector search always returns nearest neighbors even for completely irrelevant queries. Consider adding a minimum score threshold or at least documenting expected score ranges.
|
||||
4. **Hybrid ranking for single keywords** (Test 3b) — Hybrid mode on simple keyword queries produced less intuitive rankings than pure FTS or pure vector. The RRF fusion may dilute strong FTS signals.
|
||||
|
||||
### Observations
|
||||
- Vector search successfully finds conceptually related content that FTS misses entirely
|
||||
- Score ranges: relevant queries ~0.56-0.60, irrelevant queries ~0.54 (narrow spread)
|
||||
- All three result types (entity, observation, relation) appear correctly in vector results
|
||||
- Filters (entity_types, types, after_date) all work correctly with vector and hybrid modes
|
||||
- Pagination works but cross-page ordering may need investigation
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPEC-LOCAL-PLUS-PUBLISH: Local+ Published Notes and Privacy Tiers
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-02-14
|
||||
**Owner:** Basic Memory
|
||||
|
||||
## Summary
|
||||
|
||||
Add a paid Local+ feature that lets users publish selected notes to shareable URLs while keeping the
|
||||
main knowledge base local-first. Use this as a product wedge for users who do not want full cloud
|
||||
hosting but do want collaboration and distribution features.
|
||||
|
||||
This spec also captures a practical position on "zero knowledge" for Local+.
|
||||
|
||||
## Context
|
||||
|
||||
Basic Memory already has strong local-first primitives and optional cloud routing/sync. A recurring
|
||||
request is:
|
||||
|
||||
- keep knowledge local by default,
|
||||
- pay for selective value-add,
|
||||
- share specific outputs externally.
|
||||
|
||||
Published Notes fits this model: explicit per-note opt-in, reversible, and easy to understand.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Provide an Obsidian Publish-style sharing experience for selected notes.
|
||||
2. Keep local markdown files as source of truth.
|
||||
3. Make sharing compatible with current cloud/auth/billing primitives.
|
||||
4. Define clear Local+ packaging that does not degrade OSS local workflows.
|
||||
5. Document zero-knowledge constraints so product decisions are explicit.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Full hosted editing for all notes (Cloud Full remains separate).
|
||||
2. Public website builder/CMS features.
|
||||
3. Strict cryptographic zero-knowledge server processing for MCP/search in v1.
|
||||
|
||||
## Local+ Feature Catalog (Sellable)
|
||||
|
||||
Core Local+ candidates:
|
||||
|
||||
1. Published Notes (share URL, revoke, expiry, password).
|
||||
2. Snapshot Time Machine (point-in-time restore for local projects).
|
||||
3. Recovery Drill Reports (automated restore verification).
|
||||
4. Device/API Key Governance (per-device keys, revocation, audit trail).
|
||||
5. BYO Storage Orchestration (managed setup for user-owned object storage).
|
||||
6. Semantic Boost Add-on (higher quality retrieval options while files remain source-of-truth).
|
||||
|
||||
Team-oriented add-ons:
|
||||
|
||||
1. Team-owned shared links and domain branding.
|
||||
2. Role-based publish permissions.
|
||||
3. Shared workspace policies for what can be published.
|
||||
|
||||
## Proposed MVP: Published Notes
|
||||
|
||||
### User Experience
|
||||
|
||||
Per note actions:
|
||||
|
||||
1. Publish.
|
||||
2. Unpublish.
|
||||
3. Copy URL.
|
||||
4. Regenerate URL.
|
||||
5. Set visibility and controls.
|
||||
|
||||
Controls:
|
||||
|
||||
1. Visibility: `unlisted` (default) or `public`.
|
||||
2. Optional password gate.
|
||||
3. Optional expiration datetime.
|
||||
4. Optional "disable indexing" flag for public mode.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Source note remains local markdown.
|
||||
2. Publish is explicit opt-in per note.
|
||||
3. Unpublish removes public access immediately.
|
||||
4. Republish creates a new URL token unless user chooses to keep current URL.
|
||||
|
||||
### URL Model
|
||||
|
||||
1. Unlisted share URL: high-entropy token path.
|
||||
2. Public URL: slug path (optional, later phase).
|
||||
3. Team plans can support custom domain mapping in later phase.
|
||||
|
||||
### Content Model
|
||||
|
||||
v1 published page includes:
|
||||
|
||||
1. Rendered markdown body.
|
||||
2. Optional metadata (title, updated_at).
|
||||
|
||||
v1 excludes:
|
||||
|
||||
1. Full graph traversal expansion.
|
||||
2. Related note auto-discovery on public pages.
|
||||
|
||||
### Sync Model
|
||||
|
||||
1. Local file remains canonical.
|
||||
2. Publish stores a rendered snapshot plus metadata in cloud.
|
||||
3. Update path:
|
||||
- manual "update published version", or
|
||||
- optional auto-update on note change (plan-gated).
|
||||
|
||||
## Architecture (v1)
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
1. Client selects a note to publish.
|
||||
2. Client sends publish request with note identifier and policy.
|
||||
3. Service resolves note content (local sync artifact or explicit upload payload).
|
||||
4. Service stores published artifact and returns share URL.
|
||||
|
||||
### Data Model
|
||||
|
||||
`published_notes`
|
||||
|
||||
1. `id` (uuid)
|
||||
2. `tenant_id` or `workspace_id`
|
||||
3. `project_id`
|
||||
4. `entity_permalink` (or stable external_id)
|
||||
5. `share_token` (hashed in DB)
|
||||
6. `visibility` (`unlisted`|`public`)
|
||||
7. `password_hash` (nullable)
|
||||
8. `expires_at` (nullable)
|
||||
9. `is_active`
|
||||
10. `published_content` (rendered snapshot or reference)
|
||||
11. `published_at`
|
||||
12. `updated_at`
|
||||
|
||||
### API Shape (Draft)
|
||||
|
||||
1. `POST /api/published-notes`
|
||||
2. `GET /api/published-notes`
|
||||
3. `GET /api/published-notes/{id}`
|
||||
4. `PATCH /api/published-notes/{id}`
|
||||
5. `DELETE /api/published-notes/{id}` (unpublish)
|
||||
6. `POST /api/published-notes/{id}/regenerate-url`
|
||||
7. `GET /p/{token}` (public resolver)
|
||||
|
||||
### CLI Shape (Draft)
|
||||
|
||||
1. `bm cloud publish <identifier>`
|
||||
2. `bm cloud publish list`
|
||||
3. `bm cloud publish update <id>`
|
||||
4. `bm cloud publish unpublish <id>`
|
||||
5. `bm cloud publish rotate-url <id>`
|
||||
|
||||
### Security
|
||||
|
||||
1. Default to unlisted URLs.
|
||||
2. Store only hashed share tokens.
|
||||
3. Passwords hashed server-side.
|
||||
4. Enforce expiration at request time.
|
||||
5. Log publish/unpublish/rotate events for auditability.
|
||||
|
||||
## Packaging and Pricing Direction
|
||||
|
||||
Suggested split:
|
||||
|
||||
1. OSS Local: no publish URLs.
|
||||
2. Local+ Solo: publish URLs + snapshots + recovery.
|
||||
3. Local+ Team: solo features + team governance and branding.
|
||||
4. Cloud Full: hosted app + full cloud workflows.
|
||||
|
||||
Key message:
|
||||
"Keep everything local. Publish only what you choose."
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Phase 1: Unlisted publish URLs + unpublish + regenerate URL.
|
||||
2. Phase 2: Password/expiry controls.
|
||||
3. Phase 3: Auto-update on note change and basic analytics.
|
||||
4. Phase 4: Team branding/domains/policies.
|
||||
|
||||
## Zero-Knowledge Position
|
||||
|
||||
### Strict Zero-Knowledge Definition
|
||||
|
||||
Strict zero-knowledge means the server cannot decrypt note content at all.
|
||||
|
||||
### Why This Conflicts with MCP and Search
|
||||
|
||||
If server cannot decrypt:
|
||||
|
||||
1. MCP tool execution against cloud content cannot read/write semantic content.
|
||||
2. Full-text search cannot index plaintext content.
|
||||
3. Semantic/vector search cannot generate or query embeddings on plaintext.
|
||||
4. Server-side relation resolution and context building become severely limited.
|
||||
|
||||
This matches earlier findings: strict zero-knowledge materially handicaps MCP-driven behavior and
|
||||
search quality.
|
||||
|
||||
### Viable Alternatives (Not Strict Zero-Knowledge)
|
||||
|
||||
1. Encryption at rest/in transit with server-side decrypt in trusted runtime.
|
||||
- Preserves MCP/search quality.
|
||||
- Not zero-knowledge cryptographically.
|
||||
|
||||
2. Client-side retrieval mode.
|
||||
- Keep MCP/search local; cloud is sync/share/backup relay.
|
||||
- Best for privacy-first users.
|
||||
- Requires local agent availability for advanced retrieval.
|
||||
|
||||
3. Limited encrypted indexing.
|
||||
- Blind indexes for exact keywords only.
|
||||
- No high-quality semantic search.
|
||||
- Usually poor UX for natural-language memory recall.
|
||||
|
||||
### Recommendation
|
||||
|
||||
For Local+:
|
||||
|
||||
1. Do not promise strict zero-knowledge for cloud MCP/search paths.
|
||||
2. Offer a privacy-first local mode where advanced retrieval stays local.
|
||||
3. Clearly label tradeoffs:
|
||||
- "Local private mode" (best privacy, best local retrieval).
|
||||
- "Cloud-assisted mode" (best cross-device/MCP consistency, trusted-runtime decrypt).
|
||||
|
||||
This keeps messaging honest and avoids repeating the known incompatibility.
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
uv pip install -e ".[dev]"
|
||||
uv sync
|
||||
uv sync --extra semantic
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
@@ -43,9 +42,9 @@ test-unit-sqlite:
|
||||
test-unit-postgres:
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests against SQLite
|
||||
# Run integration tests against SQLite (excludes semantic benchmarks — use just test-semantic)
|
||||
test-int-sqlite:
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
|
||||
# Run integration tests against Postgres
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
@@ -56,10 +55,10 @@ test-int-postgres:
|
||||
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
|
||||
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
|
||||
if [[ -n "$TIMEOUT_CMD" ]]; then
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int' || test $? -eq 137
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
fi
|
||||
|
||||
# Run tests impacted by recent changes (requires pytest-testmon)
|
||||
@@ -99,13 +98,31 @@ postgres-migrate:
|
||||
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
|
||||
# Will be skipped automatically on non-Windows platforms
|
||||
test-windows:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
|
||||
# Run benchmark tests only (performance testing)
|
||||
# These are slow tests that measure sync performance with various file counts
|
||||
# Excluded from default test runs to keep CI fast
|
||||
test-benchmark:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
|
||||
# Run semantic search quality benchmarks (all combos)
|
||||
test-semantic:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic test-int/semantic/
|
||||
|
||||
# Run semantic benchmarks with JSON artifact output, then show report
|
||||
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 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/
|
||||
|
||||
# View semantic benchmark results (rich formatted table)
|
||||
# Usage: just semantic-report [--filter-combo sqlite] [--filter-suite paraphrase] [--sort-by avg_latency_ms]
|
||||
semantic-report *args:
|
||||
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl {{args}}
|
||||
|
||||
# Compare two search benchmark JSONL outputs
|
||||
# Usage:
|
||||
@@ -117,7 +134,7 @@ benchmark-compare baseline candidate *args:
|
||||
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
|
||||
# Use this before releasing to ensure everything works across all backends and platforms
|
||||
test-all:
|
||||
uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
@@ -192,6 +209,9 @@ update-deps:
|
||||
# Run all code quality checks and tests
|
||||
check: lint format typecheck test
|
||||
|
||||
# Run all code quality checks and all test suites, including semantic benchmarks
|
||||
check-all: lint format typecheck test test-semantic
|
||||
|
||||
# Generate Alembic migration with descriptive message
|
||||
migration message:
|
||||
cd src/basic_memory/alembic && alembic revision --autogenerate -m "{{message}}"
|
||||
|
||||
@@ -78,6 +78,7 @@ markers = [
|
||||
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
|
||||
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
|
||||
"smoke: Fast end-to-end smoke tests for MCP flows",
|
||||
"semantic: Tests requiring [semantic] extras (fastembed, sqlite-vec, openai)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -47,7 +47,6 @@ class ApiContainer:
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@@ -8,12 +8,11 @@ observations and relations.
|
||||
Flow: Entity loaded with eager observations/relations -> convert to tuples -> core functions.
|
||||
"""
|
||||
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
)
|
||||
from basic_memory.deps import EntityRepositoryV2ExternalDep
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.schemas.schema import (
|
||||
ValidationReport,
|
||||
@@ -24,11 +23,11 @@ from basic_memory.schemas.schema import (
|
||||
FieldFrequencyResponse,
|
||||
DriftFieldResponse,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
from basic_memory.schema.resolver import resolve_schema
|
||||
from basic_memory.schema.validator import validate_note
|
||||
from basic_memory.schema.inference import infer_schema, NoteData, ObservationData, RelationData
|
||||
from basic_memory.schema.diff import diff_schema
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
# Note: No prefix here -- it's added during registration as /v2/{project_id}/schema
|
||||
router = APIRouter(tags=["schema"])
|
||||
@@ -81,7 +80,6 @@ def _entity_frontmatter(entity: Entity) -> dict:
|
||||
@router.post("/schema/validate", response_model=ValidationReport)
|
||||
async def validate_schema(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_type: str | None = Query(None, description="Entity type to validate"),
|
||||
identifier: str | None = Query(None, description="Specific note identifier"),
|
||||
@@ -93,26 +91,24 @@ async def validate_schema(
|
||||
"""
|
||||
results: list[NoteValidationResponse] = []
|
||||
|
||||
async def search_fn(query: str) -> list:
|
||||
# Search for schema notes, then load full entity_metadata from the entity table.
|
||||
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
|
||||
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
|
||||
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
|
||||
frontmatters = []
|
||||
for row in results:
|
||||
if row.permalink:
|
||||
entity = await entity_repository.get_by_permalink(row.permalink)
|
||||
if entity:
|
||||
frontmatters.append(_entity_frontmatter(entity))
|
||||
return frontmatters
|
||||
|
||||
# --- Single note validation ---
|
||||
if identifier:
|
||||
entity = await entity_repository.get_by_permalink(identifier)
|
||||
if not entity:
|
||||
return ValidationReport(entity_type=entity_type, total_notes=0, results=[])
|
||||
|
||||
schema_def = await resolve_schema(_entity_frontmatter(entity), search_fn)
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.permalink or identifier,
|
||||
@@ -135,7 +131,18 @@ async def validate_schema(
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type) if entity_type else []
|
||||
|
||||
for entity in entities:
|
||||
schema_def = await resolve_schema(_entity_frontmatter(entity), search_fn)
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.permalink or entity.file_path,
|
||||
@@ -149,6 +156,7 @@ async def validate_schema(
|
||||
return ValidationReport(
|
||||
entity_type=entity_type,
|
||||
total_notes=len(results),
|
||||
total_entities=len(entities),
|
||||
valid_count=valid,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
error_count=sum(len(r.errors) for r in results),
|
||||
@@ -205,7 +213,6 @@ async def infer_schema_endpoint(
|
||||
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
|
||||
async def diff_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_type: str = Path(..., description="Entity type to check for drift"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
@@ -216,25 +223,16 @@ async def diff_schema_endpoint(
|
||||
fields, and cardinality changes.
|
||||
"""
|
||||
|
||||
async def search_fn(query: str) -> list:
|
||||
# Search for schema notes, then load full entity_metadata from the entity table.
|
||||
# The search index only stores minimal metadata (e.g., {"entity_type": "schema"}),
|
||||
# but parse_schema_note needs the full frontmatter with entity/schema/version keys.
|
||||
results = await search_service.search(SearchQuery(text=query, types=["schema"]), limit=5)
|
||||
frontmatters = []
|
||||
for row in results:
|
||||
if row.permalink:
|
||||
entity = await entity_repository.get_by_permalink(row.permalink)
|
||||
if entity:
|
||||
frontmatters.append(_entity_frontmatter(entity))
|
||||
return frontmatters
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(entity_repository, query)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
# Resolve schema by entity type
|
||||
schema_frontmatter = {"type": entity_type}
|
||||
schema_def = await resolve_schema(schema_frontmatter, search_fn)
|
||||
|
||||
if not schema_def:
|
||||
return DriftReport(entity_type=entity_type)
|
||||
return DriftReport(entity_type=entity_type, schema_found=False)
|
||||
|
||||
# Collect all notes of this type
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
@@ -281,6 +279,54 @@ async def _find_by_entity_type(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _find_schema_entities(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
target_entity_type: str,
|
||||
*,
|
||||
allow_reference_match: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""Find schema entities for resolver lookups.
|
||||
|
||||
Resolution strategy:
|
||||
1) Always try exact entity_metadata['entity'] match (for implicit type lookup
|
||||
and explicit references that use entity names)
|
||||
2) Only when allow_reference_match=True and no entity match was found, try
|
||||
exact reference matching by title/permalink (explicit schema references)
|
||||
"""
|
||||
query = entity_repository.select().where(Entity.entity_type == "schema")
|
||||
result = await entity_repository.execute_query(query)
|
||||
entities = list(result.scalars().all())
|
||||
|
||||
normalized_target = generate_permalink(target_entity_type)
|
||||
|
||||
entity_matches = [
|
||||
e
|
||||
for e in entities
|
||||
if e.entity_metadata
|
||||
and isinstance(e.entity_metadata.get("entity"), str)
|
||||
and generate_permalink(e.entity_metadata["entity"]) == normalized_target
|
||||
]
|
||||
if entity_matches:
|
||||
return entity_matches
|
||||
|
||||
if not allow_reference_match:
|
||||
return []
|
||||
|
||||
reference_matches: list[Entity] = []
|
||||
for entity in entities:
|
||||
candidate_refs: list[str] = []
|
||||
if entity.title:
|
||||
candidate_refs.append(entity.title)
|
||||
if entity.permalink:
|
||||
candidate_refs.append(entity.permalink)
|
||||
candidate_refs.append(FilePath(entity.permalink).name)
|
||||
|
||||
if any(generate_permalink(ref) == normalized_target for ref in candidate_refs):
|
||||
reference_matches.append(entity)
|
||||
|
||||
return reference_matches
|
||||
|
||||
|
||||
def _to_note_validation_response(result) -> NoteValidationResponse:
|
||||
"""Convert a core ValidationResult to a Pydantic response model."""
|
||||
return NoteValidationResponse(
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional # noqa: E402
|
||||
import typer # noqa: E402
|
||||
|
||||
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
|
||||
from basic_memory.cli.promo import maybe_show_cloud_promo # noqa: E402
|
||||
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
|
||||
|
||||
@@ -47,7 +47,15 @@ def app_callback(
|
||||
container = CliContainer.create()
|
||||
set_container(container)
|
||||
|
||||
maybe_show_cloud_promo(ctx.invoked_subcommand)
|
||||
# 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.
|
||||
maybe_show_init_line(ctx.invoked_subcommand)
|
||||
|
||||
# Trigger: register promo as a post-command callback.
|
||||
# Why: promo output should appear after the command's own output, not before.
|
||||
# Outcome: promo panel renders below the command results (status tree, table, etc.).
|
||||
ctx.call_on_close(lambda: maybe_show_cloud_promo(ctx.invoked_subcommand))
|
||||
|
||||
# Run initialization for commands that don't use the API
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format, schema, watch
|
||||
from . import (
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tool,
|
||||
project,
|
||||
format,
|
||||
schema,
|
||||
watch,
|
||||
workspace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -17,4 +26,5 @@ __all__ = [
|
||||
"format",
|
||||
"schema",
|
||||
"watch",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
@@ -30,7 +30,7 @@ console = Console()
|
||||
|
||||
@cloud_app.command()
|
||||
def login():
|
||||
"""Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
|
||||
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
|
||||
|
||||
async def _login():
|
||||
client_id, domain, host_url = get_cloud_config()
|
||||
@@ -46,21 +46,14 @@ def login():
|
||||
console.print("[dim]Verifying subscription access...[/dim]")
|
||||
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
|
||||
|
||||
# Enable cloud mode after successful login and subscription validation
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_mode = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print("[green]Cloud mode enabled[/green]")
|
||||
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
|
||||
console.print("[green]Cloud authentication successful[/green]")
|
||||
console.print(f"[dim]Cloud host ready: {host_url}[/dim]")
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(
|
||||
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] "
|
||||
"(20% off for 3 months)\n"
|
||||
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] (20% off for 3 months)\n"
|
||||
)
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
console.print(
|
||||
@@ -73,50 +66,52 @@ def login():
|
||||
|
||||
@cloud_app.command()
|
||||
def logout():
|
||||
"""Disable cloud mode and return to local mode."""
|
||||
|
||||
# Disable cloud mode
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_mode = False
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print("[green]Cloud mode disabled[/green]")
|
||||
console.print("[dim]All CLI commands now work locally[/dim]")
|
||||
"""Remove stored OAuth tokens."""
|
||||
config = ConfigManager().config
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
auth.logout()
|
||||
console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]")
|
||||
|
||||
|
||||
@cloud_app.command("status")
|
||||
def status() -> None:
|
||||
"""Check cloud mode status and cloud instance health."""
|
||||
# Check cloud mode
|
||||
"""Check cloud authentication state and cloud instance health."""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
tokens = auth.load_tokens()
|
||||
|
||||
console.print("[bold blue]Cloud Mode Status[/bold blue]")
|
||||
if config.cloud_mode:
|
||||
console.print(" Mode: [green]Cloud (enabled)[/green]")
|
||||
console.print(f" Host: {config.cloud_host}")
|
||||
console.print(" [dim]All CLI commands work against cloud[/dim]")
|
||||
else:
|
||||
console.print(" Mode: [yellow]Local (disabled)[/yellow]")
|
||||
console.print(" [dim]All CLI commands work locally[/dim]")
|
||||
console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
|
||||
return
|
||||
console.print("[bold blue]Cloud Authentication Status[/bold blue]")
|
||||
console.print(f" Host: {config.cloud_host}")
|
||||
console.print(
|
||||
f" API Key: {'[green]configured[/green]' if config.cloud_api_key else '[yellow]not set[/yellow]'}"
|
||||
)
|
||||
|
||||
oauth_status = "[yellow]not logged in[/yellow]"
|
||||
if tokens:
|
||||
oauth_status = (
|
||||
"[green]token valid[/green]"
|
||||
if auth.is_token_valid(tokens)
|
||||
else "[yellow]token expired[/yellow]"
|
||||
)
|
||||
console.print(f" OAuth: {oauth_status}")
|
||||
|
||||
# Get cloud configuration
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
# Prepare headers
|
||||
headers = {}
|
||||
has_credentials = bool(config.cloud_api_key) or tokens is not None
|
||||
if not has_credentials:
|
||||
console.print(
|
||||
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud set-key <key>[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
console.print("\n[blue]Checking cloud instance health...[/blue]")
|
||||
|
||||
# Make API request to check health
|
||||
response = run_with_cleanup(
|
||||
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
|
||||
)
|
||||
response = run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health"))
|
||||
|
||||
health_data = response.json()
|
||||
|
||||
@@ -133,11 +128,12 @@ def status() -> None:
|
||||
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error checking cloud health: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
console.print(f"[yellow]Cloud health check failed: {e}[/yellow]")
|
||||
console.print(
|
||||
"[dim]Try re-authenticating with 'bm cloud login' or setting API key with 'bm cloud set-key'.[/dim]"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
console.print(f"[yellow]Unexpected health check error: {e}[/yellow]")
|
||||
|
||||
|
||||
@cloud_app.command("setup")
|
||||
|
||||
@@ -9,8 +9,8 @@ import typer
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
@@ -55,8 +55,11 @@ async def run_sync(
|
||||
run_in_background: If True, return immediately; if False, wait for completion
|
||||
"""
|
||||
|
||||
# Resolve default project so get_client() can route per-project
|
||||
project = project or ConfigManager().default_project
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
url = f"/v2/projects/{project_item.external_id}/sync"
|
||||
params = []
|
||||
@@ -88,9 +91,8 @@ async def run_sync(
|
||||
|
||||
async def get_project_info(project: str):
|
||||
"""Get project information via API endpoint."""
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
|
||||
return ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
@@ -60,7 +60,9 @@ def import_chatgpt(
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
|
||||
# Create importer and run import
|
||||
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
|
||||
importer = ChatGPTImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
with conversations_json.open("r", encoding="utf-8") as file:
|
||||
json_data = json.load(file)
|
||||
result = run_with_cleanup(importer.import_data(json_data, folder))
|
||||
|
||||
@@ -57,7 +57,9 @@ def import_claude(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
|
||||
importer = ClaudeConversationsImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
|
||||
@@ -56,7 +56,9 @@ def import_projects(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
|
||||
importer = ClaudeProjectsImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
|
||||
@@ -55,7 +55,9 @@ def memory_json(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
|
||||
importer = MemoryJsonImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home if not destination_folder else config.home / destination_folder
|
||||
|
||||
@@ -45,10 +45,14 @@ def mcp(
|
||||
Users who have cloud mode enabled can still use local MCP for Claude Code
|
||||
and Claude Desktop while using cloud MCP for web and mobile access.
|
||||
"""
|
||||
# Force local routing for local MCP server
|
||||
# Why: The local MCP server should always talk to the local API, not the cloud proxy.
|
||||
# Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access.
|
||||
# Force local routing for local MCP server.
|
||||
# Trigger: MCP server command invocation (all transports).
|
||||
# Why: local MCP must never route through cloud; stdio in particular must
|
||||
# remain local-only to avoid cross-environment ambiguity.
|
||||
# Outcome: explicit local override disables per-project cloud routing.
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
|
||||
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
|
||||
|
||||
# Import mcp tools/prompts to register them with the server
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
|
||||
@@ -11,12 +11,13 @@ from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
@@ -46,18 +47,31 @@ def format_path(path: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _has_cloud_credentials(config) -> bool:
|
||||
"""Return whether cloud credentials are available (API key or OAuth token)."""
|
||||
if config.cloud_api_key:
|
||||
return True
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
return auth.load_tokens() is not None
|
||||
|
||||
|
||||
def _require_cloud_credentials(config) -> None:
|
||||
"""Exit with actionable guidance when cloud credentials are missing."""
|
||||
if _has_cloud_credentials(config):
|
||||
return
|
||||
|
||||
console.print("[red]Error: cloud credentials are required for this command[/red]")
|
||||
console.print("[dim]Run 'bm cloud login' or 'bm cloud set-key <key>' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("list")
|
||||
def list_projects(
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
) -> None:
|
||||
"""List all Basic Memory projects.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
"""List Basic Memory projects from local and (when available) cloud."""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
@@ -70,47 +84,112 @@ def list_projects(
|
||||
return ProjectList.model_validate(response.json())
|
||||
|
||||
try:
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(_list_projects())
|
||||
config = ConfigManager().config
|
||||
local_result: ProjectList | None = None
|
||||
cloud_result: ProjectList | None = None
|
||||
cloud_error: Exception | None = None
|
||||
|
||||
if cloud:
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(_list_projects())
|
||||
elif local:
|
||||
with force_routing(local=True):
|
||||
local_result = run_with_cleanup(_list_projects())
|
||||
else:
|
||||
# Default behavior: always show local projects first.
|
||||
with force_routing(local=True):
|
||||
local_result = run_with_cleanup(_list_projects())
|
||||
|
||||
if _has_cloud_credentials(config):
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(_list_projects())
|
||||
except Exception as exc: # pragma: no cover
|
||||
cloud_error = exc
|
||||
|
||||
table = Table(title="Basic Memory Projects")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Path", style="green")
|
||||
table.add_column("Mode", style="blue")
|
||||
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
|
||||
table.add_column("Cloud Path", style="green")
|
||||
table.add_column("CLI Route", style="blue")
|
||||
table.add_column("MCP (stdio)", style="blue")
|
||||
table.add_column("Sync", style="green")
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
# Add Local Path column if in cloud mode and not forcing local
|
||||
if config.cloud_mode_enabled and not local:
|
||||
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
|
||||
project_names_by_permalink: dict[str, str] = {}
|
||||
local_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
cloud_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
|
||||
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
|
||||
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
|
||||
if show_default_column:
|
||||
table.add_column("Default", style="magenta")
|
||||
if local_result:
|
||||
for project in local_result.projects:
|
||||
permalink = generate_permalink(project.name)
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
local_projects_by_permalink[permalink] = project
|
||||
|
||||
for project in result.projects:
|
||||
is_default = "[X]" if project.is_default else ""
|
||||
normalized_path = normalize_project_path(project.path)
|
||||
project_mode = config.get_project_mode(project.name).value
|
||||
if cloud_result:
|
||||
for project in cloud_result.projects:
|
||||
permalink = generate_permalink(project.name)
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
cloud_projects_by_permalink[permalink] = project
|
||||
|
||||
# Build row based on mode
|
||||
row = [project.name, format_path(normalized_path), project_mode]
|
||||
for permalink in sorted(project_names_by_permalink):
|
||||
project_name = project_names_by_permalink[permalink]
|
||||
local_project = local_projects_by_permalink.get(permalink)
|
||||
cloud_project = cloud_projects_by_permalink.get(permalink)
|
||||
entry = config.projects.get(project_name)
|
||||
|
||||
# Add local path if in cloud mode and not forcing local
|
||||
if config.cloud_mode_enabled and not local:
|
||||
local_path = ""
|
||||
if project.name in config.cloud_projects:
|
||||
local_path = config.cloud_projects[project.name].local_path or ""
|
||||
local_path = format_path(local_path)
|
||||
row.append(local_path)
|
||||
local_path = ""
|
||||
if local_project is not None:
|
||||
local_path = format_path(normalize_project_path(local_project.path))
|
||||
elif entry and entry.cloud_sync_path:
|
||||
local_path = format_path(entry.cloud_sync_path)
|
||||
elif entry and entry.mode == ProjectMode.LOCAL and entry.path:
|
||||
local_path = format_path(normalize_project_path(entry.path))
|
||||
|
||||
# Add default indicator if showing default column
|
||||
if show_default_column:
|
||||
row.append(is_default)
|
||||
cloud_path = ""
|
||||
if cloud_project is not None:
|
||||
cloud_path = normalize_project_path(cloud_project.path)
|
||||
|
||||
if local:
|
||||
cli_route = "local (flag)"
|
||||
elif cloud:
|
||||
cli_route = "cloud (flag)"
|
||||
elif entry:
|
||||
cli_route = entry.mode.value
|
||||
elif cloud_project is not None and local_project is None:
|
||||
cli_route = ProjectMode.CLOUD.value
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = ""
|
||||
if config.default_project == project_name:
|
||||
is_default = "[X]"
|
||||
if local_project is not None and local_project.is_default:
|
||||
is_default = "[X]"
|
||||
if cloud_project is not None and cloud_project.is_default:
|
||||
is_default = "[X]"
|
||||
|
||||
has_sync = "[X]" if entry and entry.cloud_sync_path else ""
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
|
||||
row = [
|
||||
project_name,
|
||||
local_path,
|
||||
cloud_path,
|
||||
cli_route,
|
||||
mcp_stdio_target,
|
||||
has_sync,
|
||||
is_default,
|
||||
]
|
||||
|
||||
table.add_row(*row)
|
||||
|
||||
console.print(table)
|
||||
if cloud_error is not None:
|
||||
console.print(
|
||||
"[yellow]Cloud project discovery failed. "
|
||||
"Showing local projects only. Run 'bm cloud login' or 'bm cloud set-key <key>'.[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing projects: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -151,8 +230,8 @@ def add_project(
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
# Determine effective mode: local flag forces local mode behavior
|
||||
effective_cloud_mode = config.cloud_mode_enabled and not local
|
||||
# Determine effective mode: default local, cloud only when explicitly requested.
|
||||
effective_cloud_mode = cloud and not local
|
||||
|
||||
# Resolve local sync path early (needed for both cloud and local mode)
|
||||
local_sync_path: str | None = None
|
||||
@@ -160,6 +239,7 @@ def add_project(
|
||||
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
|
||||
|
||||
if effective_cloud_mode:
|
||||
_require_cloud_credentials(config)
|
||||
# Cloud mode: path auto-generated from name, local sync is optional
|
||||
|
||||
async def _add_project():
|
||||
@@ -194,18 +274,20 @@ def add_project(
|
||||
|
||||
# Save local sync path to config if in cloud mode
|
||||
if effective_cloud_mode and local_sync_path:
|
||||
from basic_memory.config import CloudProjectConfig
|
||||
|
||||
# Create local directory if it doesn't exist
|
||||
local_dir = Path(local_sync_path)
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update config with sync path
|
||||
config.cloud_projects[name] = CloudProjectConfig(
|
||||
local_path=local_sync_path,
|
||||
last_sync=None,
|
||||
bisync_initialized=False,
|
||||
)
|
||||
# Update project entry with sync path
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.cloud_sync_path = local_sync_path
|
||||
else:
|
||||
# Project may not be in local config yet (cloud-only add)
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=local_sync_path,
|
||||
cloud_sync_path=local_sync_path,
|
||||
)
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
|
||||
@@ -229,10 +311,7 @@ def setup_project_sync(
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: sync-setup only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
@@ -246,20 +325,24 @@ def setup_project_sync(
|
||||
|
||||
try:
|
||||
# Verify project exists on cloud
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
with force_routing(cloud=True):
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
|
||||
# Resolve and create local path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
|
||||
resolved_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update local config with sync path
|
||||
from basic_memory.config import CloudProjectConfig
|
||||
|
||||
config.cloud_projects[name] = CloudProjectConfig(
|
||||
local_path=resolved_path.as_posix(),
|
||||
last_sync=None,
|
||||
bisync_initialized=False,
|
||||
)
|
||||
# Update project entry with sync path
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.cloud_sync_path = resolved_path.as_posix()
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
else:
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=resolved_path.as_posix(),
|
||||
cloud_sync_path=resolved_path.as_posix(),
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Sync configured for project '{name}'[/green]")
|
||||
@@ -316,8 +399,9 @@ def remove_project(
|
||||
local_path_config = None
|
||||
has_bisync_state = False
|
||||
|
||||
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
|
||||
local_path_config = config.cloud_projects[name].local_path
|
||||
entry = config.projects.get(name)
|
||||
if cloud and entry and entry.cloud_sync_path:
|
||||
local_path_config = entry.cloud_sync_path
|
||||
|
||||
# Check for bisync state
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
|
||||
@@ -349,9 +433,11 @@ def remove_project(
|
||||
shutil.rmtree(bisync_state_path)
|
||||
console.print("[green]Removed bisync state[/green]")
|
||||
|
||||
# Clean up cloud_projects config entry
|
||||
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
|
||||
del config.cloud_projects[name]
|
||||
# Clean up cloud sync fields on the project entry
|
||||
if cloud and entry and entry.cloud_sync_path:
|
||||
entry.cloud_sync_path = None
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Show informative message if files were not deleted
|
||||
@@ -371,21 +457,10 @@ def set_default_project(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Set the default project when 'config.default_project_mode' is set.
|
||||
"""Set the default project used as fallback when no project is specified.
|
||||
|
||||
In cloud mode, use --local to modify the local configuration.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: default project is a local configuration concept
|
||||
# Outcome: require explicit --local flag to modify local config in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'default' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project default <name> --local' to set local default[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _set_default():
|
||||
async with get_client() as client:
|
||||
@@ -422,17 +497,6 @@ def synchronize_projects(
|
||||
|
||||
In cloud mode, use --local to sync local configuration.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: sync-config syncs local config file with local database
|
||||
# Outcome: require explicit --local flag to clarify intent in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'sync-config' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project sync-config --local' to sync local config[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _sync_config():
|
||||
async with get_client() as client:
|
||||
@@ -460,18 +524,6 @@ def move_project(
|
||||
|
||||
In cloud mode, use --local to modify local project paths.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: moving a project is a local file system operation
|
||||
# Outcome: require explicit --local flag to clarify intent in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'move' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project move <name> <path> --local' to move local project[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Resolve to absolute path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
||||
|
||||
@@ -594,9 +646,7 @@ def sync_project_command(
|
||||
bm project sync --name research --dry-run
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: sync only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -613,15 +663,15 @@ def sync_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from cloud_projects config
|
||||
local_sync_path = None
|
||||
if name in config.cloud_projects:
|
||||
local_sync_path = config.cloud_projects[name].local_path
|
||||
# Get local_sync_path from project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -655,7 +705,8 @@ def sync_project_command(
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
@@ -686,9 +737,7 @@ def bisync_project_command(
|
||||
bm project bisync --name research --dry-run # Preview changes
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: bisync only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -705,15 +754,15 @@ def bisync_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from cloud_projects config
|
||||
local_sync_path = None
|
||||
if name in config.cloud_projects:
|
||||
local_sync_path = config.cloud_projects[name].local_path
|
||||
# Get local_sync_path from project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -736,9 +785,11 @@ def bisync_project_command(
|
||||
if success:
|
||||
console.print(f"[green]{name} bisync completed successfully[/green]")
|
||||
|
||||
# Update config
|
||||
config.cloud_projects[name].last_sync = datetime.now()
|
||||
config.cloud_projects[name].bisync_initialized = True
|
||||
# Update config — sync_entry is guaranteed non-None because
|
||||
# we checked local_sync_path above (which comes from sync_entry)
|
||||
assert sync_entry is not None
|
||||
sync_entry.last_sync = datetime.now()
|
||||
sync_entry.bisync_initialized = True
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
@@ -754,7 +805,8 @@ def bisync_project_command(
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
@@ -781,9 +833,7 @@ def check_project_command(
|
||||
bm project check --name research
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: check only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -800,15 +850,15 @@ def check_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from cloud_projects config
|
||||
local_sync_path = None
|
||||
if name in config.cloud_projects:
|
||||
local_sync_path = config.cloud_projects[name].local_path
|
||||
# Get local_sync_path from project entry
|
||||
check_entry = config.projects.get(name)
|
||||
local_sync_path = check_entry.cloud_sync_path if check_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -874,23 +924,52 @@ def bisync_reset(
|
||||
def ls_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to list files from"),
|
||||
path: str = typer.Argument(None, help="Path within project (optional)"),
|
||||
local: bool = typer.Option(False, "--local", help="List files from local project instance"),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="List files from cloud project instance"),
|
||||
) -> None:
|
||||
"""List files in remote project.
|
||||
"""List files in a project.
|
||||
|
||||
Examples:
|
||||
bm project ls --name research
|
||||
bm project ls --name research --local
|
||||
bm project ls --name research --cloud
|
||||
bm project ls --name research subfolder
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: ls only available in cloud mode[/red]")
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
use_cloud_route = cloud and not local
|
||||
|
||||
def _list_local_files(project_path: str, subpath: str | None = None) -> list[str]:
|
||||
project_root = Path(normalize_project_path(project_path)).expanduser().resolve()
|
||||
target_dir = project_root
|
||||
|
||||
if subpath:
|
||||
requested = Path(subpath)
|
||||
if requested.is_absolute():
|
||||
raise ValueError("Path must be relative to the project root")
|
||||
target_dir = (project_root / requested).resolve()
|
||||
if not target_dir.is_relative_to(project_root):
|
||||
raise ValueError("Path must stay within the project root")
|
||||
|
||||
if not target_dir.exists():
|
||||
raise ValueError(f"Path not found: {target_dir}")
|
||||
if not target_dir.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {target_dir}")
|
||||
|
||||
files: list[str] = []
|
||||
for file_path in sorted(target_dir.rglob("*")):
|
||||
if file_path.is_file():
|
||||
size = file_path.stat().st_size
|
||||
relative = file_path.relative_to(project_root).as_posix()
|
||||
files.append(f"{size:10d} {relative}")
|
||||
|
||||
return files
|
||||
|
||||
try:
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
@@ -901,29 +980,46 @@ def ls_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
if use_cloud_route:
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
# Create SyncProject (local_sync_path not needed for ls)
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
)
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# List files
|
||||
files = project_ls(sync_project, bucket_name, path=path)
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
)
|
||||
files = project_ls(sync_project, bucket_name, path=path)
|
||||
target_label = "CLOUD"
|
||||
else:
|
||||
with force_routing(local=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
files = _list_local_files(project_data.path, path)
|
||||
target_label = "LOCAL"
|
||||
|
||||
if files:
|
||||
console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]")
|
||||
heading = f"\n[bold]Files in {name} ({target_label})"
|
||||
if path:
|
||||
heading += f"/{path}"
|
||||
heading += ":[/bold]"
|
||||
console.print(heading)
|
||||
for file in files:
|
||||
console.print(f" {file}")
|
||||
console.print(f"\n[dim]Total: {len(files)} files[/dim]")
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]"
|
||||
)
|
||||
prefix = f"[yellow]No files found in {name} ({target_label})"
|
||||
console.print(prefix + (f"/{path}" if path else "") + "[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""CLI routing utilities for --local/--cloud flag handling.
|
||||
|
||||
This module provides utilities for CLI commands to override the default routing
|
||||
behavior (determined by cloud_mode_enabled in config). This allows users to:
|
||||
|
||||
1. Use local MCP server even when cloud mode is enabled
|
||||
2. Force local routing for specific CLI commands with --local flag
|
||||
3. Force cloud routing with --cloud flag (requires authentication)
|
||||
This module provides utilities for CLI commands to override default routing.
|
||||
This allows users to force local or cloud routing per-command.
|
||||
|
||||
The routing is controlled via environment variables:
|
||||
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
|
||||
- BASIC_MEMORY_FORCE_CLOUD: When "true", forces cloud proxy transport
|
||||
- BASIC_MEMORY_EXPLICIT_ROUTING: When "true", signals that --local/--cloud
|
||||
was explicitly passed, overriding per-project routing in get_client()
|
||||
- These are checked in basic_memory.mcp.async_client.get_client()
|
||||
"""
|
||||
|
||||
@@ -24,9 +23,14 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
|
||||
Sets environment variables that are checked by get_client() to determine
|
||||
whether to use local ASGI transport or cloud proxy transport.
|
||||
|
||||
When either flag is set, BASIC_MEMORY_EXPLICIT_ROUTING is also set so
|
||||
that get_client() skips per-project routing and honors the flag directly.
|
||||
This only affects CLI commands — the MCP server sets FORCE_LOCAL directly
|
||||
(without EXPLICIT_ROUTING), so per-project routing still works for MCP tools.
|
||||
|
||||
Args:
|
||||
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
|
||||
cloud: If True, clear force_local to allow cloud routing
|
||||
local: If True, force local ASGI transport
|
||||
cloud: If True, force cloud proxy transport
|
||||
|
||||
Usage:
|
||||
with force_routing(local=True):
|
||||
@@ -41,23 +45,37 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
|
||||
|
||||
# Save original values
|
||||
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
|
||||
original_force_cloud = os.environ.get("BASIC_MEMORY_FORCE_CLOUD")
|
||||
original_explicit = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
|
||||
|
||||
try:
|
||||
if local:
|
||||
# Force local routing by setting the env var
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
|
||||
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
|
||||
elif cloud:
|
||||
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
os.environ["BASIC_MEMORY_FORCE_CLOUD"] = "true"
|
||||
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
|
||||
# If neither is set, don't change anything (use default behavior)
|
||||
yield
|
||||
finally:
|
||||
# Restore original value
|
||||
# Restore original values
|
||||
if original_force_local is None:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
else:
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
|
||||
|
||||
if original_force_cloud is None:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
|
||||
else:
|
||||
os.environ["BASIC_MEMORY_FORCE_CLOUD"] = original_force_cloud
|
||||
|
||||
if original_explicit is None:
|
||||
os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None)
|
||||
else:
|
||||
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = original_explicit
|
||||
|
||||
|
||||
def validate_routing_flags(local: bool, cloud: bool) -> None:
|
||||
"""Validate that --local and --cloud flags are not both specified.
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Annotated, Optional
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
@@ -49,11 +48,11 @@ async def _run_validate(
|
||||
"""Run schema validation via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
|
||||
# Determine if target is a note identifier or entity type
|
||||
# Determine if target is a note identifier or note type
|
||||
# Heuristic: if target contains / or ., treat as identifier
|
||||
entity_type = None
|
||||
identifier = None
|
||||
@@ -70,7 +69,13 @@ async def _run_validate(
|
||||
|
||||
# --- Display results ---
|
||||
if report.total_notes == 0:
|
||||
console.print("[yellow]No notes matched for validation.[/yellow]")
|
||||
if report.total_entities == 0:
|
||||
console.print(f"[yellow]No notes of type '{entity_type}' found.[/yellow]")
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]Found {report.total_entities} notes but no schema "
|
||||
f"defined for '{entity_type}'.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(title=f"Schema Validation: {entity_type or identifier or 'all'}")
|
||||
@@ -109,7 +114,7 @@ async def _run_validate(
|
||||
def validate(
|
||||
target: Annotated[
|
||||
Optional[str],
|
||||
typer.Argument(help="Note path or entity type to validate"),
|
||||
typer.Argument(help="Note path or note type to validate"),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
@@ -123,8 +128,8 @@ def validate(
|
||||
):
|
||||
"""Validate notes against their schemas.
|
||||
|
||||
TARGET can be a note path (e.g., people/ada-lovelace.md) or an entity type
|
||||
(e.g., Person). If omitted, validates all notes that have schemas.
|
||||
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
|
||||
(e.g., person). If omitted, validates all notes that have schemas.
|
||||
|
||||
Use --strict to exit with error code 1 if any validation errors are found.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
@@ -158,7 +163,7 @@ async def _run_infer(
|
||||
"""Run schema inference via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
|
||||
@@ -168,6 +173,27 @@ async def _run_infer(
|
||||
console.print(f"[yellow]No notes found with type: {entity_type}[/yellow]")
|
||||
return
|
||||
|
||||
# --- Empty schema guard ---
|
||||
# Trigger: notes were analyzed but no fields met the threshold
|
||||
# Why: dumping hundreds of excluded fields is not useful output
|
||||
# Outcome: show count and suggest a more specific type
|
||||
if not report.suggested_schema:
|
||||
console.print(
|
||||
f"\n[yellow]Analyzed {report.notes_analyzed} notes of type '{entity_type}', "
|
||||
f"but no fields met the {threshold:.0%} threshold.[/yellow]\n"
|
||||
)
|
||||
console.print(
|
||||
f"This usually means '{entity_type}' is too broad — "
|
||||
f"the notes don't share a consistent structure.\n"
|
||||
)
|
||||
console.print("[bold]Suggestions:[/bold]")
|
||||
console.print(" 1. Use a more specific type")
|
||||
console.print(
|
||||
f" 2. Lower the threshold: bm schema infer {entity_type} --threshold 0.1"
|
||||
)
|
||||
console.print(" 3. Create typed notes with write_note using a specific note_type")
|
||||
return
|
||||
|
||||
# --- Display frequency analysis ---
|
||||
console.print(
|
||||
f"\n[bold]Analyzing {report.notes_analyzed} notes with type: {entity_type}...[/bold]\n"
|
||||
@@ -201,7 +227,7 @@ async def _run_infer(
|
||||
|
||||
# --- Display suggested schema ---
|
||||
console.print("\n[bold]Suggested schema:[/bold]")
|
||||
console.print(Panel(json.dumps(report.suggested_schema, indent=2), title="Picoschema"))
|
||||
console.print(json.dumps(report.suggested_schema, indent=2))
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
@@ -214,7 +240,7 @@ async def _run_infer(
|
||||
def infer(
|
||||
entity_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Entity type to analyze (e.g., Person, meeting)"),
|
||||
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
|
||||
],
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
@@ -231,7 +257,7 @@ def infer(
|
||||
):
|
||||
"""Infer schema from existing notes of a type.
|
||||
|
||||
Analyzes all notes with the given entity type and suggests a Picoschema
|
||||
Analyzes all notes with the given type and suggests a Picoschema
|
||||
definition based on observation and relation frequency.
|
||||
|
||||
Fields present in 95%+ of notes become required. Fields above the
|
||||
@@ -266,7 +292,7 @@ async def _run_diff(
|
||||
"""Run schema drift detection via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
|
||||
@@ -300,7 +326,7 @@ async def _run_diff(
|
||||
def diff(
|
||||
entity_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Entity type to check for drift"),
|
||||
typer.Argument(help="Note type to check for drift"),
|
||||
],
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
@@ -313,8 +339,8 @@ def diff(
|
||||
):
|
||||
"""Show drift between schema and actual usage.
|
||||
|
||||
Compares the existing schema definition for an entity type against
|
||||
how notes of that type are actually structured. Identifies new fields,
|
||||
Compares the existing schema definition against how notes of that type
|
||||
are actually structured. Identifies new fields,
|
||||
dropped fields, and cardinality changes.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
|
||||
@@ -12,6 +12,7 @@ from rich.tree import Tree
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas import SyncReportResponse
|
||||
@@ -142,9 +143,11 @@ def display_changes(
|
||||
|
||||
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
|
||||
"""Check sync status of files vs database."""
|
||||
# Resolve default project so get_client() can route per-project
|
||||
project = project or ConfigManager().default_project
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
response = await call_post(client, f"/v2/projects/{project_item.external_id}/status")
|
||||
sync_report = SyncReportResponse.model_validate(response.json())
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Annotated, List, Optional
|
||||
from typing import Annotated, Any, List, Optional
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from rich import print as rprint
|
||||
|
||||
@@ -12,9 +13,8 @@ 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.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.base import Entity, TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url_path
|
||||
@@ -24,10 +24,8 @@ from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.mcp.prompts.continue_conversation import (
|
||||
continue_conversation as mcp_continue_conversation,
|
||||
)
|
||||
from basic_memory.mcp.prompts.recent_activity import (
|
||||
recent_activity_prompt as recent_activity_prompt,
|
||||
)
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
from basic_memory.mcp.tools import edit_note as mcp_edit_note
|
||||
from basic_memory.mcp.tools import read_note as mcp_read_note
|
||||
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
|
||||
from basic_memory.mcp.tools import search_notes as mcp_search
|
||||
@@ -36,6 +34,60 @@ from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
|
||||
|
||||
VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"]
|
||||
|
||||
|
||||
# --- Frontmatter helpers ---
|
||||
|
||||
|
||||
def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Parse and strip an opening YAML frontmatter block if valid.
|
||||
|
||||
Returns a tuple of (body_content_or_original, parsed_frontmatter_or_none).
|
||||
|
||||
Behavior:
|
||||
- Only parses frontmatter if the first line is an opening '---' delimiter.
|
||||
- Requires a closing '---' delimiter.
|
||||
- Accepts mapping YAML only; malformed or non-mapping YAML is ignored.
|
||||
- Supports UTF-8 BOM at document start.
|
||||
"""
|
||||
if not content:
|
||||
return content, None
|
||||
|
||||
original_content = content
|
||||
if content.startswith("\ufeff"):
|
||||
content = content[1:]
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
if not lines:
|
||||
return original_content, None
|
||||
|
||||
if lines[0].rstrip("\r\n").strip() != "---":
|
||||
return original_content, None
|
||||
|
||||
closing_index = None
|
||||
for index in range(1, len(lines)):
|
||||
if lines[index].rstrip("\r\n").strip() == "---":
|
||||
closing_index = index
|
||||
break
|
||||
|
||||
if closing_index is None:
|
||||
return original_content, None
|
||||
|
||||
frontmatter_text = "".join(lines[1:closing_index])
|
||||
try:
|
||||
parsed = yaml.safe_load(frontmatter_text) if frontmatter_text else {}
|
||||
except yaml.YAMLError:
|
||||
return original_content, None
|
||||
|
||||
if parsed is None:
|
||||
parsed = {}
|
||||
if not isinstance(parsed, dict):
|
||||
return original_content, None
|
||||
|
||||
body_content = "".join(lines[closing_index + 1 :])
|
||||
return body_content, parsed
|
||||
|
||||
|
||||
# --- JSON output helpers ---
|
||||
# These async functions bypass the MCP tool (which returns formatted strings)
|
||||
@@ -43,15 +95,26 @@ app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
|
||||
|
||||
|
||||
async def _write_note_json(
|
||||
title: str, content: str, folder: str, project_name: Optional[str], tags: Optional[List[str]]
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
project_name: Optional[str],
|
||||
workspace: Optional[str],
|
||||
tags: Optional[List[str]],
|
||||
) -> dict:
|
||||
"""Write a note and return structured JSON metadata."""
|
||||
# Use the MCP tool to create/update the entity (handles create-or-update logic)
|
||||
await mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
await mcp_write_note.fn(
|
||||
title=title,
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Resolve the entity to get metadata back
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
entity = Entity(title=title, directory=folder)
|
||||
@@ -69,11 +132,14 @@ async def _write_note_json(
|
||||
|
||||
|
||||
async def _read_note_json(
|
||||
identifier: str, project_name: Optional[str], page: int, page_size: int
|
||||
identifier: str,
|
||||
project_name: Optional[str],
|
||||
workspace: Optional[str],
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> dict:
|
||||
"""Read a note and return structured JSON with content and metadata."""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
@@ -90,12 +156,18 @@ async def _read_note_json(
|
||||
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
|
||||
|
||||
title_results = await mcp_search_tool.fn(
|
||||
query=identifier, search_type="title", project=project_name
|
||||
query=identifier,
|
||||
search_type="title",
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
)
|
||||
if title_results and hasattr(title_results, "results") and title_results.results:
|
||||
result = title_results.results[0]
|
||||
if result.permalink:
|
||||
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
||||
results = title_results.get("results", []) if isinstance(title_results, dict) else []
|
||||
if results:
|
||||
result = results[0]
|
||||
permalink = result.get("permalink")
|
||||
if permalink:
|
||||
entity_id = await knowledge_client.resolve_entity(permalink)
|
||||
|
||||
if entity_id is None:
|
||||
raise ValueError(f"Could not find note matching: {identifier}")
|
||||
@@ -111,16 +183,72 @@ async def _read_note_json(
|
||||
}
|
||||
|
||||
|
||||
async def _edit_note_json(
|
||||
identifier: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
project_name: Optional[str],
|
||||
workspace: Optional[str],
|
||||
section: Optional[str],
|
||||
find_text: Optional[str],
|
||||
expected_replacements: int,
|
||||
) -> dict:
|
||||
"""Edit a note and return structured JSON metadata."""
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
|
||||
edit_data: dict[str, Any] = {
|
||||
"operation": operation,
|
||||
"content": content,
|
||||
"expected_replacements": expected_replacements,
|
||||
}
|
||||
if section:
|
||||
edit_data["section"] = section
|
||||
if find_text:
|
||||
edit_data["find_text"] = find_text
|
||||
|
||||
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"operation": operation,
|
||||
"checksum": result.checksum,
|
||||
}
|
||||
|
||||
|
||||
def _validate_edit_note_args(
|
||||
operation: str, find_text: Optional[str], section: Optional[str]
|
||||
) -> None:
|
||||
"""Validate operation-specific required arguments for edit-note."""
|
||||
if operation not in VALID_EDIT_OPERATIONS:
|
||||
raise ValueError(
|
||||
f"Invalid operation '{operation}'. Must be one of: {', '.join(VALID_EDIT_OPERATIONS)}"
|
||||
)
|
||||
if operation == "find_replace" and not find_text:
|
||||
raise ValueError("find_text parameter is required for find_replace operation")
|
||||
if operation == "replace_section" and not section:
|
||||
raise ValueError("section parameter is required for replace_section operation")
|
||||
|
||||
|
||||
def _is_edit_note_failure_response(result: str) -> bool:
|
||||
"""Check whether the MCP edit_note text response indicates a failed edit."""
|
||||
return result.lstrip().startswith("# Edit Failed")
|
||||
|
||||
|
||||
async def _recent_activity_json(
|
||||
type: Optional[List[SearchItemType]],
|
||||
depth: Optional[int],
|
||||
timeframe: Optional[TimeFrame],
|
||||
project_name: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> list:
|
||||
"""Get recent activity and return structured JSON list."""
|
||||
async with get_client() as client:
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
# Build query params matching the MCP tool's logic
|
||||
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
|
||||
if depth:
|
||||
@@ -130,7 +258,6 @@ async def _recent_activity_json(
|
||||
if type:
|
||||
params["type"] = [t.value for t in type]
|
||||
|
||||
active_project = await get_active_project(client, project_name)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/memory/recent",
|
||||
@@ -164,6 +291,10 @@ def write_note(
|
||||
help="The project to write to. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
content: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
@@ -251,12 +382,19 @@ def write_note(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_write_note_json(title, content, folder, project_name, tags)
|
||||
_write_note_json(title, content, folder, project_name, workspace, tags)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
note = run_with_cleanup(
|
||||
mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
mcp_write_note.fn(
|
||||
title=title,
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
tags=tags,
|
||||
)
|
||||
)
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
@@ -278,9 +416,21 @@ def read_note(
|
||||
help="The project to use for the note. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
format: str = typer.Option("text", "--format", help="Output format: text or json"),
|
||||
strip_frontmatter: bool = typer.Option(
|
||||
False,
|
||||
"--strip-frontmatter",
|
||||
help=(
|
||||
"Strip opening YAML frontmatter from content. "
|
||||
"JSON output includes parsed frontmatter under 'frontmatter'."
|
||||
),
|
||||
),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -290,6 +440,7 @@ def read_note(
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
Use --strip-frontmatter to return body-only markdown content.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
@@ -309,11 +460,25 @@ def read_note(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_read_note_json(identifier, project_name, page, page_size)
|
||||
_read_note_json(identifier, project_name, workspace, page, page_size)
|
||||
)
|
||||
stripped_content, parsed_frontmatter = _parse_opening_frontmatter(result["content"])
|
||||
result["frontmatter"] = parsed_frontmatter
|
||||
if strip_frontmatter:
|
||||
result["content"] = stripped_content
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
|
||||
note = run_with_cleanup(
|
||||
mcp_read_note.fn(
|
||||
identifier=identifier,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
if strip_frontmatter:
|
||||
note, _ = _parse_opening_frontmatter(note)
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
@@ -325,6 +490,101 @@ def read_note(
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def edit_note(
|
||||
identifier: str,
|
||||
operation: Annotated[str, typer.Option("--operation", help="Edit operation to apply")],
|
||||
content: Annotated[str, typer.Option("--content", help="Content for the edit operation")],
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
help="The project to edit. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
find_text: Annotated[
|
||||
Optional[str], typer.Option("--find-text", help="Text to find for find_replace operation")
|
||||
] = None,
|
||||
section: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--section", help="Section heading for replace_section operation"),
|
||||
] = None,
|
||||
expected_replacements: int = typer.Option(
|
||||
1,
|
||||
"--expected-replacements",
|
||||
help="Expected replacement count for find_replace operation",
|
||||
),
|
||||
format: str = typer.Option("text", "--format", help="Output format: text or json"),
|
||||
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"),
|
||||
):
|
||||
"""Edit an existing markdown note using append/prepend/find_replace/replace_section.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
_validate_edit_note_args(operation, find_text, section)
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_edit_note_json(
|
||||
identifier=identifier,
|
||||
operation=operation,
|
||||
content=content,
|
||||
project_name=project_name,
|
||||
workspace=workspace,
|
||||
section=section,
|
||||
find_text=find_text,
|
||||
expected_replacements=expected_replacements,
|
||||
)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
result = run_with_cleanup(
|
||||
mcp_edit_note.fn(
|
||||
identifier=identifier,
|
||||
operation=operation,
|
||||
content=content,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
section=section,
|
||||
find_text=find_text,
|
||||
expected_replacements=expected_replacements,
|
||||
)
|
||||
)
|
||||
rprint(result)
|
||||
if _is_edit_note_failure_response(result):
|
||||
raise typer.Exit(1)
|
||||
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 edit_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def build_context(
|
||||
url: MemoryUrl,
|
||||
@@ -332,6 +592,10 @@ def build_context(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
@@ -364,19 +628,23 @@ def build_context(
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
context = run_with_cleanup(
|
||||
result = run_with_cleanup(
|
||||
mcp_build_context.fn(
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
output_format="text" if format == "text" else "json",
|
||||
)
|
||||
)
|
||||
context_dict = context.model_dump(exclude_none=True)
|
||||
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
|
||||
if format == "json":
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
print(result)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -394,6 +662,10 @@ def recent_activity(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = typer.Option(1, "--page", help="Page number for pagination (JSON format)"),
|
||||
@@ -427,7 +699,15 @@ def recent_activity(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_recent_activity_json(type, depth, timeframe, project_name, page, page_size)
|
||||
_recent_activity_json(
|
||||
type=type,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
project_name=project_name,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
@@ -437,6 +717,7 @@ def recent_activity(
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
# The tool returns a formatted string directly
|
||||
@@ -467,6 +748,10 @@ def search_notes(
|
||||
help="The project to use for the note. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
] = None,
|
||||
after_date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
|
||||
@@ -578,8 +863,9 @@ def search_notes(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
results = run_with_cleanup(
|
||||
mcp_search.fn(
|
||||
query or "",
|
||||
project_name,
|
||||
query=query or "",
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
@@ -642,22 +928,3 @@ def continue_conversation(
|
||||
typer.echo(f"Error continuing conversation: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
# @tool_app.command(name="show-recent-activity")
|
||||
# def show_recent_activity(
|
||||
# timeframe: Annotated[
|
||||
# str, typer.Option(help="How far back to look for activity")
|
||||
# ] = "7d",
|
||||
# ):
|
||||
# """Prompt to show recent activity."""
|
||||
# try:
|
||||
# # Prompt functions return formatted strings directly
|
||||
# session = asyncio.run(recent_activity_prompt(timeframe=timeframe))
|
||||
# rprint(session)
|
||||
# except Exception as e: # pragma: no cover
|
||||
# if not isinstance(e, typer.Exit):
|
||||
# logger.exception("Error continuing conversation", e)
|
||||
# typer.echo(f"Error continuing conversation: {e}", err=True)
|
||||
# raise typer.Exit(1)
|
||||
# raise
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Workspace commands for Basic Memory cloud workspaces."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
|
||||
console = Console()
|
||||
|
||||
workspace_app = typer.Typer(help="Manage cloud workspaces")
|
||||
app.add_typer(workspace_app, name="workspace")
|
||||
|
||||
|
||||
@workspace_app.command("list")
|
||||
def list_workspaces() -> None:
|
||||
"""List cloud workspaces available to the current OAuth session."""
|
||||
|
||||
async def _list():
|
||||
return await get_available_workspaces()
|
||||
|
||||
try:
|
||||
workspaces = run_with_cleanup(_list())
|
||||
except RuntimeError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc: # pragma: no cover
|
||||
console.print(f"[red]Error listing workspaces: {exc}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not workspaces:
|
||||
console.print("[yellow]No accessible workspaces found.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Available Workspaces")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="blue")
|
||||
table.add_column("Role", style="green")
|
||||
table.add_column("Tenant ID", style="yellow")
|
||||
|
||||
for workspace in workspaces:
|
||||
table.add_row(
|
||||
workspace.name,
|
||||
workspace.workspace_type,
|
||||
workspace.role,
|
||||
workspace.tenant_id,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command("workspaces")
|
||||
def workspaces_alias() -> None:
|
||||
"""Alias for `bm workspace list`."""
|
||||
list_workspaces()
|
||||
@@ -35,7 +35,6 @@ class CliContainer:
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@@ -5,6 +5,7 @@ import warnings
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
|
||||
|
||||
def _version_only_invocation(argv: list[str]) -> bool:
|
||||
# Trigger: invocation is exactly `bm --version` or `bm -v`
|
||||
# Why: avoid importing command modules on the hot version path
|
||||
@@ -27,6 +28,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
schema,
|
||||
status,
|
||||
tool,
|
||||
workspace,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
CLOUD_PROMO_VERSION = "2026-02-06"
|
||||
OSS_DISCOUNT_CODE = "{{OSS_DISCOUNT_CODE}}"
|
||||
OSS_DISCOUNT_CODE = "BMFOSS"
|
||||
CLOUD_LEARN_MORE_URL = "https://basicmemory.com"
|
||||
|
||||
|
||||
def _promos_disabled_by_env() -> bool:
|
||||
@@ -23,24 +24,44 @@ def _is_interactive_session() -> bool:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
|
||||
def _build_first_run_message() -> str:
|
||||
"""Build first-run cloud promo copy."""
|
||||
def _build_cloud_promo_message() -> str:
|
||||
"""Build benefit-led cloud upsell copy with Rich markup."""
|
||||
return (
|
||||
"Basic Memory initialized (local mode).\n"
|
||||
"Cloud is optional and keeps your workflow local-first.\n"
|
||||
"Cloud adds cross-device sync + mobile/web access.\n"
|
||||
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
|
||||
"Run `bm cloud login` to enable."
|
||||
"☁️ [bold]Your knowledge, everywhere.[/bold] ✨\n"
|
||||
"Stop losing context when you switch machines.\n"
|
||||
"Basic Memory Cloud syncs your memory across every device, including mobile and web.\n"
|
||||
"Try it free for 7 days.\n"
|
||||
f"Use [bold cyan]{OSS_DISCOUNT_CODE}[/bold cyan] for 20% off when you subscribe.\n"
|
||||
"[bold green]→ bm cloud login[/bold green]"
|
||||
)
|
||||
|
||||
|
||||
def _build_version_message() -> str:
|
||||
"""Build cloud promo copy shown after promo-version bumps."""
|
||||
return (
|
||||
"New in Basic Memory Cloud: cross-device sync + mobile/web access.\n"
|
||||
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
|
||||
"Run `bm cloud login` to enable."
|
||||
)
|
||||
def maybe_show_init_line(
|
||||
invoked_subcommand: str | None,
|
||||
*,
|
||||
config_manager: ConfigManager | None = None,
|
||||
is_interactive: bool | None = None,
|
||||
console: Console | None = None,
|
||||
) -> None:
|
||||
"""Show a one-time init confirmation line before command output."""
|
||||
manager = config_manager or ConfigManager()
|
||||
config = manager.load_config()
|
||||
|
||||
interactive = _is_interactive_session() if is_interactive is None else is_interactive
|
||||
|
||||
# Same gates as the cloud promo — suppress in non-interactive, env kill-switch,
|
||||
# mcp/root-help contexts, or when already shown.
|
||||
if _promos_disabled_by_env() or not interactive:
|
||||
return
|
||||
|
||||
if invoked_subcommand in {None, "mcp"}:
|
||||
return
|
||||
|
||||
if config.cloud_promo_first_run_shown:
|
||||
return
|
||||
|
||||
out = console or Console()
|
||||
out.print("Basic Memory initialized ✓")
|
||||
|
||||
|
||||
def maybe_show_cloud_promo(
|
||||
@@ -48,11 +69,15 @@ def maybe_show_cloud_promo(
|
||||
*,
|
||||
config_manager: ConfigManager | None = None,
|
||||
is_interactive: bool | None = None,
|
||||
echo: Callable[[str], None] = typer.echo,
|
||||
console: Console | None = None,
|
||||
) -> None:
|
||||
"""Show cloud promo copy when discovery gates are satisfied."""
|
||||
manager = config_manager or ConfigManager()
|
||||
config = manager.load_config()
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
has_cloud_access = bool(config.cloud_api_key) or auth.load_tokens() is not None
|
||||
|
||||
interactive = _is_interactive_session() if is_interactive is None else is_interactive
|
||||
|
||||
@@ -68,17 +93,26 @@ def maybe_show_cloud_promo(
|
||||
if invoked_subcommand in {None, "mcp"}:
|
||||
return
|
||||
|
||||
if config.cloud_mode_enabled or config.cloud_promo_opt_out:
|
||||
if has_cloud_access or config.cloud_promo_opt_out:
|
||||
return
|
||||
|
||||
show_first_run = not config.cloud_promo_first_run_shown
|
||||
show_version_notice = config.cloud_promo_last_version_shown != CLOUD_PROMO_VERSION
|
||||
show_version_notice = config.cloud_promo_last_version_shown != basic_memory.__version__
|
||||
if not show_first_run and not show_version_notice:
|
||||
return
|
||||
|
||||
message = _build_first_run_message() if show_first_run else _build_version_message()
|
||||
echo(message)
|
||||
out = console or Console()
|
||||
out.print(
|
||||
Panel(
|
||||
_build_cloud_promo_message(),
|
||||
title="Basic Memory Cloud",
|
||||
border_style="cyan",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
out.print(f"Learn more at [link={CLOUD_LEARN_MORE_URL}]{CLOUD_LEARN_MORE_URL}[/link]")
|
||||
out.print("[dim]Disable with: bm cloud promo --off[/dim]")
|
||||
|
||||
config.cloud_promo_first_run_shown = True
|
||||
config.cloud_promo_last_version_shown = CLOUD_PROMO_VERSION
|
||||
config.cloud_promo_last_version_shown = basic_memory.__version__
|
||||
manager.save_config(config)
|
||||
|
||||
+194
-65
@@ -1,5 +1,6 @@
|
||||
"""Configuration management for basic-memory."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@@ -38,6 +39,11 @@ class DatabaseBackend(str, Enum):
|
||||
POSTGRES = "postgres"
|
||||
|
||||
|
||||
def _default_semantic_search_enabled() -> bool:
|
||||
"""Enable semantic search by default when semantic extras are installed."""
|
||||
return importlib.util.find_spec("fastembed") is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
@@ -60,6 +66,9 @@ class CloudProjectConfig(BaseModel):
|
||||
|
||||
This tracks the local working directory and sync state for a project
|
||||
that is synced with Basic Memory Cloud.
|
||||
|
||||
DEPRECATED: Kept for backward-compatible migration only. New code should
|
||||
use ProjectEntry fields (cloud_sync_path, bisync_initialized, last_sync).
|
||||
"""
|
||||
|
||||
local_path: str = Field(description="Local working directory path for this cloud project")
|
||||
@@ -71,26 +80,52 @@ class CloudProjectConfig(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ProjectEntry(BaseModel):
|
||||
"""Unified project configuration entry.
|
||||
|
||||
Replaces the old triple of projects (Dict[str, str]), project_modes
|
||||
(Dict[str, ProjectMode]), and cloud_projects (Dict[str, CloudProjectConfig])
|
||||
with a single structure per project.
|
||||
"""
|
||||
|
||||
path: str = Field(description="Local filesystem path for the project")
|
||||
mode: ProjectMode = Field(
|
||||
default=ProjectMode.LOCAL,
|
||||
description="Routing mode: local (in-process ASGI) or cloud (remote API)",
|
||||
)
|
||||
# Cloud sync state (replaces CloudProjectConfig)
|
||||
cloud_sync_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Local working directory for bisync (formerly CloudProjectConfig.local_path)",
|
||||
)
|
||||
bisync_initialized: bool = Field(
|
||||
default=False,
|
||||
description="Whether rclone bisync baseline has been established",
|
||||
)
|
||||
last_sync: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Timestamp of last successful sync operation",
|
||||
)
|
||||
|
||||
|
||||
class BasicMemoryConfig(BaseSettings):
|
||||
"""Pydantic model for Basic Memory global configuration."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
projects: Dict[str, ProjectEntry] = Field(
|
||||
default_factory=lambda: {
|
||||
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
"main": ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
)
|
||||
}
|
||||
if os.getenv("BASIC_MEMORY_HOME")
|
||||
else {},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
description="Mapping of project names to their ProjectEntry configuration",
|
||||
)
|
||||
default_project: str = Field(
|
||||
default_project: Optional[str] = Field(
|
||||
default="main",
|
||||
description="Name of the default project to use",
|
||||
)
|
||||
default_project_mode: bool = Field(
|
||||
default=True,
|
||||
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
|
||||
description="Name of the default project to use. When set, acts as fallback when no project parameter is specified. Set to null to disable automatic project resolution.",
|
||||
)
|
||||
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
@@ -109,7 +144,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
# Semantic search configuration
|
||||
semantic_search_enabled: bool = Field(
|
||||
default=False,
|
||||
default_factory=_default_semantic_search_enabled,
|
||||
description="Enable semantic search (vector/hybrid retrieval). Works on both SQLite and Postgres backends. Requires semantic extras.",
|
||||
)
|
||||
semantic_embedding_provider: str = Field(
|
||||
@@ -134,6 +169,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Vector candidate count for vector and hybrid retrieval.",
|
||||
gt=0,
|
||||
)
|
||||
semantic_min_similarity: float = Field(
|
||||
default=0.55,
|
||||
description="Minimum similarity score for vector search results. Results below this threshold are filtered out. 0.0 disables filtering.",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
)
|
||||
|
||||
# Database connection pool configuration (Postgres only)
|
||||
db_pool_size: int = Field(
|
||||
@@ -196,6 +237,16 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
|
||||
)
|
||||
|
||||
ensure_frontmatter_on_sync: bool = Field(
|
||||
default=False,
|
||||
description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.",
|
||||
)
|
||||
|
||||
permalinks_include_project: bool = Field(
|
||||
default=True,
|
||||
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",
|
||||
)
|
||||
|
||||
skip_initialization_sync: bool = Field(
|
||||
default=False,
|
||||
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
||||
@@ -247,16 +298,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Basic Memory Cloud host URL",
|
||||
)
|
||||
|
||||
cloud_mode: bool = Field(
|
||||
default=False,
|
||||
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
||||
)
|
||||
|
||||
cloud_projects: Dict[str, CloudProjectConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
cloud_promo_opt_out: bool = Field(
|
||||
default=False,
|
||||
description="Disable CLI cloud promo messages when true.",
|
||||
@@ -277,10 +318,78 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.",
|
||||
)
|
||||
|
||||
project_modes: Dict[str, ProjectMode] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-project routing mode. Projects not listed default to LOCAL.",
|
||||
)
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def migrate_legacy_projects(cls, data: Any) -> Any:
|
||||
"""Migrate old-format config (Dict[str, str]) to new ProjectEntry format.
|
||||
|
||||
Old format stored projects as three separate dicts:
|
||||
projects: {"name": "/path"}
|
||||
project_modes: {"name": "cloud"}
|
||||
cloud_projects: {"name": {"local_path": "...", ...}}
|
||||
|
||||
New format unifies them into:
|
||||
projects: {"name": {"path": "/path", "mode": "cloud", ...}}
|
||||
|
||||
Also removes stale keys (default_project_mode, permalinks_include_project)
|
||||
that are no longer part of the config model.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# --- Remove stale keys from old config versions ---
|
||||
data.pop("default_project_mode", None)
|
||||
data.pop("cloud_mode", None)
|
||||
|
||||
projects = data.get("projects", {})
|
||||
if not projects:
|
||||
return data
|
||||
|
||||
# Check if already in new format — peek at first value
|
||||
first_value = next(iter(projects.values()), None)
|
||||
if isinstance(first_value, str):
|
||||
# Old format: {"name": "/path"} → convert
|
||||
project_modes = data.pop("project_modes", {})
|
||||
cloud_projects = data.pop("cloud_projects", {})
|
||||
new_projects: Dict[str, Any] = {}
|
||||
for name, path in projects.items():
|
||||
entry: Dict[str, Any] = {"path": path}
|
||||
if name in project_modes:
|
||||
entry["mode"] = project_modes[name]
|
||||
if name in cloud_projects:
|
||||
cp = cloud_projects[name]
|
||||
if isinstance(cp, dict):
|
||||
entry["cloud_sync_path"] = cp.get("local_path")
|
||||
entry["bisync_initialized"] = cp.get("bisync_initialized", False)
|
||||
entry["last_sync"] = cp.get("last_sync")
|
||||
else:
|
||||
# Already a CloudProjectConfig-like object
|
||||
entry["cloud_sync_path"] = getattr(cp, "local_path", None)
|
||||
entry["bisync_initialized"] = getattr(cp, "bisync_initialized", False)
|
||||
entry["last_sync"] = getattr(cp, "last_sync", None)
|
||||
new_projects[name] = entry
|
||||
|
||||
# Pick up cloud_projects entries not already in projects
|
||||
# These are cloud-only projects — path is the cloud permalink,
|
||||
# local_path goes into cloud_sync_path for bisync
|
||||
for name, cp in cloud_projects.items():
|
||||
if name not in new_projects:
|
||||
if isinstance(cp, dict):
|
||||
new_projects[name] = {
|
||||
"path": generate_permalink(name),
|
||||
"mode": project_modes.get(name, "cloud"),
|
||||
"cloud_sync_path": cp.get("local_path"),
|
||||
"bisync_initialized": cp.get("bisync_initialized", False),
|
||||
"last_sync": cp.get("last_sync"),
|
||||
}
|
||||
|
||||
data["projects"] = new_projects
|
||||
else:
|
||||
# New format or dict-based — just clean up stale keys
|
||||
data.pop("project_modes", None)
|
||||
data.pop("cloud_projects", None)
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def is_test_env(self) -> bool:
|
||||
@@ -299,42 +408,31 @@ class BasicMemoryConfig(BaseSettings):
|
||||
or os.getenv("PYTEST_CURRENT_TEST") is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def cloud_mode_enabled(self) -> bool:
|
||||
"""Check if cloud mode is enabled.
|
||||
|
||||
Priority:
|
||||
1. BASIC_MEMORY_CLOUD_MODE environment variable
|
||||
2. Config file value (cloud_mode)
|
||||
"""
|
||||
env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
|
||||
if env_value in ("true", "1", "yes"):
|
||||
return True
|
||||
elif env_value in ("false", "0", "no"):
|
||||
return False
|
||||
# Fall back to config file value
|
||||
return self.cloud_mode
|
||||
|
||||
def get_project_mode(self, project_name: str) -> ProjectMode:
|
||||
"""Get the routing mode for a project.
|
||||
|
||||
Returns the per-project mode if set, otherwise LOCAL.
|
||||
"""
|
||||
return self.project_modes.get(project_name, ProjectMode.LOCAL)
|
||||
entry = self.projects.get(project_name)
|
||||
return entry.mode if entry else ProjectMode.LOCAL
|
||||
|
||||
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
|
||||
"""Set the routing mode for a project."""
|
||||
if mode == ProjectMode.LOCAL:
|
||||
# Remove from dict to keep config clean — LOCAL is the default
|
||||
self.project_modes.pop(project_name, None)
|
||||
"""Set the routing mode for a project.
|
||||
|
||||
Creates a minimal ProjectEntry if the project doesn't already exist,
|
||||
preserving backward compatibility with code that sets mode before
|
||||
adding a full project entry.
|
||||
"""
|
||||
if project_name in self.projects:
|
||||
self.projects[project_name].mode = mode
|
||||
else:
|
||||
self.project_modes[project_name] = mode
|
||||
self.projects[project_name] = ProjectEntry(path="", mode=mode)
|
||||
|
||||
@classmethod
|
||||
def for_cloud_tenant(
|
||||
cls,
|
||||
database_url: str,
|
||||
projects: Optional[Dict[str, str]] = None,
|
||||
projects: Optional[Dict[str, "ProjectEntry"]] = None,
|
||||
) -> "BasicMemoryConfig":
|
||||
"""Create config for cloud tenant - no config.json, database is source of truth.
|
||||
|
||||
@@ -356,7 +454,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
database_backend=DatabaseBackend.POSTGRES,
|
||||
database_url=database_url,
|
||||
projects=projects or {},
|
||||
cloud_mode=True,
|
||||
skip_initialization_sync=True,
|
||||
)
|
||||
|
||||
@@ -372,7 +469,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if name not in self.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
return Path(self.projects[name])
|
||||
return Path(self.projects[name].path)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
@@ -382,12 +479,15 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
# Ensure at least one project exists; if none exist then create main
|
||||
if not self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
self.projects["main"] = ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
)
|
||||
|
||||
# Ensure default project is valid (i.e. points to an existing project)
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
# None means "no default" — intentionally left unset
|
||||
if (
|
||||
self.default_project is not None and self.default_project not in self.projects
|
||||
): # pragma: no cover
|
||||
# Set default to first available project
|
||||
self.default_project = next(iter(self.projects.keys()))
|
||||
|
||||
@@ -424,8 +524,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
|
||||
"""Get all configured projects as ProjectConfig objects."""
|
||||
return [
|
||||
ProjectConfig(name=name, home=Path(path), mode=self.get_project_mode(name))
|
||||
for name, path in self.projects.items()
|
||||
ProjectConfig(name=name, home=Path(entry.path), mode=entry.mode)
|
||||
for name, entry in self.projects.items()
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -439,8 +539,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return self
|
||||
|
||||
for name, path_value in self.projects.items():
|
||||
path = Path(path_value)
|
||||
for name, entry in self.projects.items():
|
||||
path = Path(entry.path)
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
@@ -506,6 +606,22 @@ class ConfigManager:
|
||||
try:
|
||||
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
||||
|
||||
# Detect legacy format before model validators strip stale keys
|
||||
_STALE_KEYS = {
|
||||
"default_project_mode",
|
||||
"project_modes",
|
||||
"cloud_projects",
|
||||
"cloud_mode",
|
||||
}
|
||||
needs_resave = bool(_STALE_KEYS & file_data.keys())
|
||||
|
||||
# Check if projects dict uses old string-value format
|
||||
projects_raw = file_data.get("projects", {})
|
||||
if projects_raw:
|
||||
first_val = next(iter(projects_raw.values()), None)
|
||||
if isinstance(first_val, str):
|
||||
needs_resave = True
|
||||
|
||||
# First, create config from environment variables (Pydantic will read them)
|
||||
# Then overlay with file data for fields that aren't set via env vars
|
||||
# This ensures env vars take precedence
|
||||
@@ -527,6 +643,12 @@ class ConfigManager:
|
||||
merged_data[field_name] = env_dict[field_name]
|
||||
|
||||
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
||||
|
||||
# Re-save to normalize legacy config into current format
|
||||
if needs_resave:
|
||||
logger.info("Migrating config to current format")
|
||||
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
|
||||
|
||||
return _CONFIG_CACHE
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception(f"Failed to load config: {e}")
|
||||
@@ -545,11 +667,15 @@ class ConfigManager:
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
"""Get all configured projects."""
|
||||
return self.config.projects.copy()
|
||||
"""Get all configured projects as name -> path mapping.
|
||||
|
||||
Returns the legacy Dict[str, str] format for backward compatibility
|
||||
with code that expects project name -> filesystem path.
|
||||
"""
|
||||
return {name: entry.path for name, entry in self.config.projects.items()}
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
def default_project(self) -> Optional[str]:
|
||||
"""Get the default project name."""
|
||||
return self.config.default_project
|
||||
|
||||
@@ -565,7 +691,7 @@ class ConfigManager:
|
||||
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = str(project_path)
|
||||
config.projects[name] = ProjectEntry(path=str(project_path))
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
@@ -597,12 +723,15 @@ class ConfigManager:
|
||||
self.save_config(config)
|
||||
|
||||
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
||||
"""Look up a project from the configuration by name or permalink"""
|
||||
"""Look up a project from the configuration by name or permalink.
|
||||
|
||||
Returns (project_name, path_string) for backward compatibility.
|
||||
"""
|
||||
project_permalink = generate_permalink(name)
|
||||
app_config = self.config
|
||||
for project_name, path in app_config.projects.items():
|
||||
for project_name, entry in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(project_name):
|
||||
return project_name, path
|
||||
return project_name, entry.path
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -637,9 +766,9 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
|
||||
project_permalink = generate_permalink(actual_project_name)
|
||||
|
||||
for name, path in app_config.projects.items():
|
||||
for name, entry in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(name):
|
||||
return ProjectConfig(name=name, home=Path(path))
|
||||
return ProjectConfig(name=name, home=Path(entry.path))
|
||||
|
||||
# otherwise raise error
|
||||
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
||||
|
||||
+16
-7
@@ -344,24 +344,33 @@ async def engine_session_factory(
|
||||
|
||||
global _engine, _session_maker
|
||||
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
# Use the same helper function as production code.
|
||||
#
|
||||
# Keep local references so teardown can deterministically dispose the
|
||||
# specific engine created by this context manager, even if other code calls
|
||||
# shutdown_db() and mutates module-level globals mid-test.
|
||||
created_engine, created_session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
_engine, _session_maker = created_engine, created_session_maker
|
||||
|
||||
try:
|
||||
# Verify that engine and session maker are initialized
|
||||
if _engine is None: # pragma: no cover
|
||||
if created_engine is None: # pragma: no cover
|
||||
logger.error("Database engine is None in engine_session_factory")
|
||||
raise RuntimeError("Database engine initialization failed")
|
||||
|
||||
if _session_maker is None: # pragma: no cover
|
||||
if created_session_maker is None: # pragma: no cover
|
||||
logger.error("Session maker is None in engine_session_factory")
|
||||
raise RuntimeError("Session maker initialization failed")
|
||||
|
||||
yield _engine, _session_maker
|
||||
yield created_engine, created_session_maker
|
||||
finally:
|
||||
if _engine:
|
||||
await _engine.dispose()
|
||||
await 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.
|
||||
if _engine is created_engine:
|
||||
_engine = None
|
||||
if _session_maker is created_session_maker:
|
||||
_session_maker = None
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,12 @@ async def get_chatgpt_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
return ChatGPTImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
||||
@@ -53,7 +58,12 @@ async def get_chatgpt_importer_v2( # pragma: no cover
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
return ChatGPTImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
@@ -65,7 +75,12 @@ async def get_chatgpt_importer_v2_external(
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 external_id dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
return ChatGPTImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
|
||||
@@ -80,7 +95,12 @@ async def get_claude_conversations_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeConversationsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeConversationsImporterDep = Annotated[
|
||||
@@ -94,7 +114,12 @@ async def get_claude_conversations_importer_v2( # pragma: no cover
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeConversationsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
@@ -108,7 +133,12 @@ async def get_claude_conversations_importer_v2_external(
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeConversationsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2ExternalDep = Annotated[
|
||||
@@ -125,7 +155,12 @@ async def get_claude_projects_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeProjectsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
||||
@@ -137,7 +172,12 @@ async def get_claude_projects_importer_v2( # pragma: no cover
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeProjectsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
@@ -151,7 +191,12 @@ async def get_claude_projects_importer_v2_external(
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
return ClaudeProjectsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2ExternalDep = Annotated[
|
||||
@@ -168,7 +213,12 @@ async def get_memory_json_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
return MemoryJsonImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
@@ -180,7 +230,12 @@ async def get_memory_json_importer_v2( # pragma: no cover
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
return MemoryJsonImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
@@ -192,7 +247,12 @@ async def get_memory_json_importer_v2_external(
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 external_id dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
return MemoryJsonImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
|
||||
|
||||
MemoryJsonImporterV2ExternalDep = Annotated[
|
||||
|
||||
@@ -8,6 +8,7 @@ This module provides service-layer dependencies:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol
|
||||
|
||||
from fastapi import Depends
|
||||
@@ -446,13 +447,22 @@ def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
|
||||
|
||||
class LocalTaskScheduler:
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task."""
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task.
|
||||
|
||||
In test mode (BASIC_MEMORY_ENV=test), tasks run as no-ops to avoid
|
||||
background asyncio tasks racing against test teardown and causing
|
||||
SQLite 'cannot commit transaction' errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: Mapping[str, Callable[..., Coroutine[Any, Any, None]]],
|
||||
test_mode: bool | None = None,
|
||||
) -> None:
|
||||
self._handlers = handlers
|
||||
self._test_mode = (
|
||||
test_mode if test_mode is not None else os.environ.get("BASIC_MEMORY_ENV") == "test"
|
||||
)
|
||||
|
||||
def schedule(self, task_name: str, **payload: Any) -> None:
|
||||
handler = self._handlers.get(task_name)
|
||||
@@ -461,6 +471,15 @@ class LocalTaskScheduler:
|
||||
# Outcome: fail fast to surface misconfiguration
|
||||
if not handler:
|
||||
raise ValueError(f"Unknown task name: {task_name}")
|
||||
|
||||
# Trigger: running inside pytest (BASIC_MEMORY_ENV=test)
|
||||
# Why: background create_task() outlives test fixtures and races
|
||||
# against engine disposal, causing flaky SQLite errors
|
||||
# Outcome: skip background scheduling; tests exercise the sync
|
||||
# codepaths directly when they need to
|
||||
if self._test_mode:
|
||||
return
|
||||
|
||||
task = asyncio.create_task(handler(**payload))
|
||||
task.add_done_callback(_log_task_failure)
|
||||
|
||||
@@ -516,7 +535,8 @@ async def get_task_scheduler(
|
||||
"sync_entity_vectors": _sync_entity_vectors,
|
||||
"sync_project": _sync_project,
|
||||
"reindex_project": _reindex_project,
|
||||
}
|
||||
},
|
||||
test_mode=app_config.is_test_env,
|
||||
)
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown
|
||||
from basic_memory.schemas.importer import ImportResult
|
||||
from basic_memory.utils import build_canonical_permalink, generate_permalink
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.services.file_service import FileService
|
||||
@@ -29,6 +30,7 @@ class Importer[T: ImportResult]:
|
||||
base_path: Path,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
file_service: "FileService",
|
||||
project_name: Optional[str] = None,
|
||||
):
|
||||
"""Initialize the import service.
|
||||
|
||||
@@ -40,6 +42,8 @@ class Importer[T: ImportResult]:
|
||||
self.base_path = base_path.resolve() # Get absolute path
|
||||
self.markdown_processor = markdown_processor
|
||||
self.file_service = file_service
|
||||
self.project_name = project_name
|
||||
self.project_permalink = generate_permalink(project_name) if project_name else None
|
||||
|
||||
@abstractmethod
|
||||
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
|
||||
@@ -73,6 +77,26 @@ class Importer[T: ImportResult]:
|
||||
# FileService.write_file handles directory creation and returns checksum
|
||||
return await self.file_service.write_file(file_path, content)
|
||||
|
||||
def canonical_permalink(self, path: str) -> str:
|
||||
"""Build a canonical permalink for imported content."""
|
||||
include_project = True
|
||||
# Trigger: importer has app config with permalink prefixing flag
|
||||
# Why: imported notes should align with canonical permalink format
|
||||
# Outcome: include project prefix when enabled
|
||||
if self.file_service.app_config is not None:
|
||||
include_project = self.file_service.app_config.permalinks_include_project
|
||||
|
||||
return build_canonical_permalink(
|
||||
self.project_permalink,
|
||||
path,
|
||||
include_project=include_project,
|
||||
)
|
||||
|
||||
def build_import_paths(self, path: str) -> tuple[str, str]:
|
||||
"""Return (permalink, file_path) for an imported entity."""
|
||||
permalink = self.canonical_permalink(path)
|
||||
return permalink, f"{path}.md"
|
||||
|
||||
async def ensure_folder_exists(self, folder: str) -> None:
|
||||
"""Ensure folder exists using FileService.
|
||||
|
||||
|
||||
@@ -51,11 +51,20 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
created_at = chat["create_time"]
|
||||
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
|
||||
clean_title = clean_filename(chat["title"])
|
||||
relative_path = (
|
||||
f"{destination_folder}/{date_prefix}-{clean_title}"
|
||||
if destination_folder
|
||||
else f"{date_prefix}-{clean_title}"
|
||||
)
|
||||
permalink, file_path = self.build_import_paths(relative_path)
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(destination_folder, chat)
|
||||
entity = self._format_chat_content(chat, permalink)
|
||||
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
# Count messages
|
||||
@@ -83,7 +92,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
return self.handle_error("Failed to import ChatGPT conversations", e)
|
||||
|
||||
def _format_chat_content(
|
||||
self, folder: str, conversation: Dict[str, Any]
|
||||
self, conversation: Dict[str, Any], permalink: str
|
||||
) -> EntityMarkdown: # pragma: no cover
|
||||
"""Convert chat conversation to Basic Memory entity.
|
||||
|
||||
@@ -105,10 +114,6 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
root_id = node_id
|
||||
break
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
|
||||
clean_title = clean_filename(conversation["title"])
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
title=conversation["title"],
|
||||
@@ -126,7 +131,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"title": conversation["title"],
|
||||
"created": format_timestamp(created_at),
|
||||
"modified": format_timestamp(modified_at),
|
||||
"permalink": f"{folder}/{date_prefix}-{clean_title}",
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
|
||||
@@ -54,18 +54,27 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
for chat in conversations:
|
||||
# Get name, providing default for unnamed conversations
|
||||
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
|
||||
date_prefix = datetime.fromisoformat(
|
||||
chat["created_at"].replace("Z", "+00:00")
|
||||
).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(chat_name)
|
||||
relative_path = (
|
||||
f"{destination_folder}/{date_prefix}-{clean_title}"
|
||||
if destination_folder
|
||||
else f"{date_prefix}-{clean_title}"
|
||||
)
|
||||
permalink, file_path = self.build_import_paths(relative_path)
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
folder=destination_folder,
|
||||
name=chat_name,
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
permalink=permalink,
|
||||
)
|
||||
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
chats_imported += 1
|
||||
@@ -84,11 +93,11 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
|
||||
def _format_chat_content(
|
||||
self,
|
||||
folder: str,
|
||||
name: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
created_at: str,
|
||||
modified_at: str,
|
||||
permalink: str,
|
||||
) -> EntityMarkdown:
|
||||
"""Convert chat messages to Basic Memory entity format.
|
||||
|
||||
@@ -102,11 +111,6 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
Returns:
|
||||
EntityMarkdown instance representing the conversation.
|
||||
"""
|
||||
# Generate permalink using folder name (relative path)
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{folder}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
name=name,
|
||||
|
||||
@@ -63,17 +63,28 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
await self.file_service.ensure_directory(docs_dir)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(prompt_entity, file_path)
|
||||
if project.get("prompt_template"):
|
||||
prompt_path = (
|
||||
f"{destination_folder}/{project_dir}/prompt-template"
|
||||
if destination_folder
|
||||
else f"{project_dir}/prompt-template"
|
||||
)
|
||||
permalink, file_path = self.build_import_paths(prompt_path)
|
||||
prompt_entity = self._format_prompt_markdown(project, permalink)
|
||||
if prompt_entity:
|
||||
await self.write_entity(prompt_entity, file_path)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = self._format_project_markdown(project, doc, destination_folder)
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
doc_path = (
|
||||
f"{destination_folder}/{project_dir}/docs/{doc_file}"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs/{doc_file}"
|
||||
)
|
||||
permalink, file_path = self.build_import_paths(doc_path)
|
||||
entity = self._format_project_markdown(project, doc, permalink)
|
||||
await self.write_entity(entity, file_path)
|
||||
docs_imported += 1
|
||||
|
||||
@@ -89,7 +100,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
return self.handle_error("Failed to import Claude projects", e)
|
||||
|
||||
def _format_project_markdown(
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any], permalink: str
|
||||
) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity.
|
||||
|
||||
@@ -105,17 +116,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
created_at = doc.get("created_at") or project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean names for organization
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/docs/{doc_file}"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs/{doc_file}"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
@@ -136,7 +136,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
return entity
|
||||
|
||||
def _format_prompt_markdown(
|
||||
self, project: Dict[str, Any], destination_folder: str = ""
|
||||
self, project: Dict[str, Any], permalink: str
|
||||
) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity.
|
||||
|
||||
@@ -155,16 +155,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
created_at = project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/prompt-template"
|
||||
if destination_folder
|
||||
else f"{project_dir}/prompt-template"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
|
||||
@@ -80,11 +80,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
relative_path = (
|
||||
f"{destination_folder}/{entity_type}/{name}"
|
||||
if destination_folder
|
||||
else f"{entity_type}/{name}"
|
||||
)
|
||||
permalink, file_path = self.build_import_paths(relative_path)
|
||||
|
||||
# Ensure entity type directory exists using FileService with relative path
|
||||
entity_type_dir = (
|
||||
@@ -109,7 +110,6 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
)
|
||||
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
entities_created += 1
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Markdown-it plugins for Basic Memory markdown parsing."""
|
||||
|
||||
from typing import List, Any, Dict
|
||||
|
||||
from basic_memory.utils import normalize_project_reference
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.token import Token
|
||||
|
||||
@@ -114,7 +116,7 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
|
||||
rel_type = before
|
||||
|
||||
# Get target
|
||||
target = content[start + 2 : end].strip()
|
||||
target = normalize_project_reference(content[start + 2 : end].strip())
|
||||
|
||||
# Look for context after
|
||||
after = content[end + 2 :].strip()
|
||||
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
|
||||
# No matching ]] found
|
||||
break
|
||||
|
||||
target = content[start + 2 : end].strip()
|
||||
target = normalize_project_reference(content[start + 2 : end].strip())
|
||||
if target:
|
||||
relations.append({"type": "links_to", "target": target, "context": None})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
|
||||
from httpx import ASGITransport, AsyncClient, Timeout
|
||||
@@ -10,36 +10,98 @@ from basic_memory.config import ConfigManager, ProjectMode
|
||||
|
||||
|
||||
def _force_local_mode() -> bool:
|
||||
"""Check if local mode is forced via environment variable.
|
||||
|
||||
This allows commands like `bm mcp` to force local routing even when
|
||||
cloud_mode_enabled is True in config. The local MCP server should
|
||||
always talk to the local API, not the cloud proxy.
|
||||
|
||||
Returns:
|
||||
True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value
|
||||
"""
|
||||
"""Check if local mode is forced via environment variable."""
|
||||
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _force_cloud_mode() -> bool:
|
||||
"""Check if cloud mode is forced via environment variable."""
|
||||
return os.environ.get("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _explicit_routing() -> bool:
|
||||
"""Check if CLI --local/--cloud flag was explicitly passed."""
|
||||
return os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _build_timeout() -> Timeout:
|
||||
"""Create a standard timeout config used across all clients."""
|
||||
return Timeout(
|
||||
connect=10.0,
|
||||
read=30.0,
|
||||
write=30.0,
|
||||
pool=30.0,
|
||||
)
|
||||
|
||||
|
||||
def _asgi_client(timeout: Timeout) -> AsyncClient:
|
||||
"""Create a local ASGI client."""
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_cloud_token(config) -> str:
|
||||
"""Resolve cloud token with API key preferred, OAuth fallback."""
|
||||
token = config.cloud_api_key
|
||||
if token:
|
||||
return token
|
||||
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
token = await auth.get_valid_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
raise RuntimeError(
|
||||
"Cloud routing requested but no credentials found. "
|
||||
"Run 'bm cloud set-key <key>' or 'bm cloud login' first."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cloud_client(
|
||||
config,
|
||||
timeout: Timeout,
|
||||
workspace: Optional[str] = None,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""Create a cloud proxy client with resolved credentials."""
|
||||
token = await _resolve_cloud_token(config)
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if workspace:
|
||||
headers["X-Workspace-ID"] = workspace
|
||||
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_cloud_control_plane_client() -> AsyncIterator[AsyncClient]:
|
||||
"""Create a control-plane cloud client for endpoints outside /proxy."""
|
||||
config = ConfigManager().config
|
||||
timeout = _build_timeout()
|
||||
token = await _resolve_cloud_token(config)
|
||||
logger.info(f"Creating HTTP client for cloud control plane at: {config.cloud_host}")
|
||||
async with AsyncClient(
|
||||
base_url=config.cloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# Optional factory override for dependency injection
|
||||
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
|
||||
|
||||
|
||||
def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
|
||||
"""Override the default client factory (for cloud app, testing, etc).
|
||||
|
||||
Args:
|
||||
factory: An async context manager that yields an AsyncClient
|
||||
|
||||
Example:
|
||||
@asynccontextmanager
|
||||
async def custom_client_factory():
|
||||
async with AsyncClient(...) as client:
|
||||
yield client
|
||||
|
||||
set_client_factory(custom_client_factory)
|
||||
"""
|
||||
"""Override the default client factory (for cloud app, testing, etc)."""
|
||||
global _client_factory
|
||||
_client_factory = factory
|
||||
|
||||
@@ -47,189 +109,82 @@ def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncCl
|
||||
@asynccontextmanager
|
||||
async def get_client(
|
||||
project_name: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""Get an AsyncClient as a context manager.
|
||||
|
||||
This function provides proper resource management for HTTP clients,
|
||||
ensuring connections are closed after use. Routing priority:
|
||||
|
||||
1. **Factory injection** (cloud app, tests):
|
||||
If a custom factory is set via set_client_factory(), use that.
|
||||
|
||||
2. **Per-project cloud mode** (project_name provided):
|
||||
If the project's mode is CLOUD, routes to cloud using API key or
|
||||
OAuth token. Honored even when FORCE_LOCAL is set, because the user
|
||||
explicitly declared this project as cloud.
|
||||
|
||||
3. **Per-project local mode** (project_name provided):
|
||||
If the project's mode is LOCAL (or unspecified, default LOCAL), route
|
||||
to local ASGI transport. This allows mixed local/cloud routing even when
|
||||
global cloud mode is enabled.
|
||||
|
||||
4. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
|
||||
Routes to local ASGI transport, ignoring global cloud settings.
|
||||
|
||||
5. **Global cloud mode** (deprecated fallback):
|
||||
When cloud_mode_enabled is True, uses OAuth JWT token.
|
||||
|
||||
6. **Local mode** (default):
|
||||
Use ASGI transport for in-process requests to local FastAPI app.
|
||||
|
||||
Args:
|
||||
project_name: Optional project name for per-project routing.
|
||||
If provided and the project's mode is CLOUD, routes to cloud
|
||||
using the API key or OAuth token.
|
||||
|
||||
Usage:
|
||||
async with get_client() as client:
|
||||
response = await client.get("/path")
|
||||
|
||||
# Per-project routing
|
||||
async with get_client(project_name="research") as client:
|
||||
response = await client.get("/path")
|
||||
|
||||
Yields:
|
||||
AsyncClient: Configured HTTP client for the current mode
|
||||
|
||||
Raises:
|
||||
RuntimeError: If cloud routing needed but no API key / not authenticated
|
||||
Routing priority:
|
||||
1. Factory injection.
|
||||
2. Explicit routing flags (--local/--cloud).
|
||||
3. Per-project mode routing when project_name is provided.
|
||||
4. Local ASGI transport by default.
|
||||
"""
|
||||
if _client_factory:
|
||||
# Use injected factory (cloud app, tests)
|
||||
async with _client_factory() as client:
|
||||
yield client
|
||||
else:
|
||||
# Default: create based on config
|
||||
config = ConfigManager().config
|
||||
timeout = Timeout(
|
||||
connect=10.0, # 10 seconds for connection
|
||||
read=30.0, # 30 seconds for reading response
|
||||
write=30.0, # 30 seconds for writing request
|
||||
pool=30.0, # 30 seconds for connection pool
|
||||
)
|
||||
return
|
||||
|
||||
# Trigger: project has per-project cloud mode set
|
||||
# Why: per-project CLOUD is an explicit user declaration that should be
|
||||
# honored even from the MCP server (which sets FORCE_LOCAL)
|
||||
# Outcome: HTTP client with API key or OAuth auth to cloud proxy
|
||||
if project_name and config.get_project_mode(project_name) == ProjectMode.CLOUD:
|
||||
# Try API key first (explicit, no network)
|
||||
token = config.cloud_api_key
|
||||
if not token:
|
||||
# Fall back to OAuth session (may refresh token)
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
config = ConfigManager().config
|
||||
timeout = _build_timeout()
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
token = await auth.get_valid_token()
|
||||
# --- Explicit routing override ---
|
||||
# Trigger: user passed --local/--cloud.
|
||||
# Why: command-level override should be deterministic and bypass project mode.
|
||||
# Outcome: route strictly based on explicit flag.
|
||||
if _explicit_routing():
|
||||
if _force_local_mode():
|
||||
logger.info("Explicit local routing enabled - using ASGI client")
|
||||
async with _asgi_client(timeout) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
if not token:
|
||||
if _force_cloud_mode():
|
||||
logger.info("Explicit cloud routing enabled - using cloud proxy client")
|
||||
async with _cloud_client(config, timeout, workspace=workspace) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
# --- Per-project routing ---
|
||||
# Trigger: project_name provided without explicit routing override.
|
||||
# Why: project mode is the source of truth for project-scoped commands.
|
||||
# Outcome: route via project.mode (CLOUD/LOCAL).
|
||||
if project_name is not None and not _explicit_routing():
|
||||
project_mode = config.get_project_mode(project_name)
|
||||
if project_mode == ProjectMode.CLOUD:
|
||||
logger.info(f"Project '{project_name}' is cloud mode - using cloud proxy client")
|
||||
try:
|
||||
async with _cloud_client(config, timeout, workspace=workspace) as client:
|
||||
yield client
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(
|
||||
f"Project '{project_name}' is set to cloud mode but no credentials found. "
|
||||
"Run 'bm cloud set-key <key>' or 'bm cloud login' first."
|
||||
)
|
||||
) from exc
|
||||
return
|
||||
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(
|
||||
f"Creating HTTP client for cloud project '{project_name}' at: {proxy_base_url}"
|
||||
)
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
logger.info(f"Project '{project_name}' is local mode - using ASGI client")
|
||||
async with _asgi_client(timeout) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
# Trigger: project is not explicitly cloud (LOCAL is the default)
|
||||
# Why: project-scoped routing should honor local mode even when global
|
||||
# cloud mode is enabled for backward compatibility
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
elif project_name and config.get_project_mode(project_name) == ProjectMode.LOCAL:
|
||||
logger.info(f"Project '{project_name}' is set to local mode - using ASGI transport")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
|
||||
# Why: allows local MCP server and CLI commands to route locally
|
||||
# even when cloud_mode_enabled is True
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
elif _force_local_mode():
|
||||
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
elif config.cloud_mode_enabled:
|
||||
# Global cloud mode (deprecated fallback): inject OAuth auth when creating client
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
token = await auth.get_valid_token()
|
||||
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
"Cloud mode enabled but not authenticated. "
|
||||
"Run 'basic-memory cloud login' first."
|
||||
)
|
||||
|
||||
# Auth header set ONCE at client creation
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
yield client
|
||||
else:
|
||||
# Local mode: ASGI transport for in-process calls
|
||||
# Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
) as client:
|
||||
yield client
|
||||
# --- Default fallback ---
|
||||
logger.info("Default routing - using ASGI client for local Basic Memory API")
|
||||
async with _asgi_client(timeout) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def create_client() -> AsyncClient:
|
||||
"""Create an HTTP client based on configuration.
|
||||
"""Create an HTTP client based on explicit routing flags.
|
||||
|
||||
DEPRECATED: Use get_client() context manager instead for proper resource management.
|
||||
|
||||
This function is kept for backward compatibility but will be removed in a future version.
|
||||
The returned client should be closed manually by calling await client.aclose().
|
||||
|
||||
Returns:
|
||||
AsyncClient configured for either local ASGI or remote proxy
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
timeout = _build_timeout()
|
||||
|
||||
# Configure timeout for longer operations like write_note
|
||||
# Default httpx timeout is 5 seconds which is too short for file operations
|
||||
timeout = Timeout(
|
||||
connect=10.0, # 10 seconds for connection
|
||||
read=30.0, # 30 seconds for reading response
|
||||
write=30.0, # 30 seconds for writing request
|
||||
pool=30.0, # 30 seconds for connection pool
|
||||
)
|
||||
|
||||
# Check force local first (for local MCP server and CLI --local flag)
|
||||
if _force_local_mode():
|
||||
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
)
|
||||
elif config.cloud_mode_enabled:
|
||||
# Use HTTP transport to proxy endpoint
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
|
||||
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
|
||||
else:
|
||||
# Default: use ASGI transport for local API (development mode)
|
||||
if _force_local_mode() or not _force_cloud_mode():
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
)
|
||||
return _asgi_client(timeout)
|
||||
|
||||
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
|
||||
config = ConfigManager().config
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
|
||||
|
||||
@@ -224,11 +224,12 @@ class KnowledgeClient:
|
||||
|
||||
# --- Resolution ---
|
||||
|
||||
async def resolve_entity(self, identifier: str) -> str:
|
||||
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
|
||||
"""Resolve a string identifier to an entity external_id.
|
||||
|
||||
Args:
|
||||
identifier: The identifier to resolve (permalink, title, or path)
|
||||
strict: If True, require exact matching (no fuzzy fallback)
|
||||
|
||||
Returns:
|
||||
The resolved entity external_id (UUID)
|
||||
@@ -239,7 +240,7 @@ class KnowledgeClient:
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/resolve",
|
||||
json={"identifier": identifier},
|
||||
json={"identifier": identifier, "strict": strict},
|
||||
)
|
||||
data = response.json()
|
||||
return data["external_id"]
|
||||
|
||||
@@ -39,7 +39,6 @@ class McpContainer:
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
from typing import Sequence
|
||||
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult
|
||||
|
||||
@@ -84,7 +84,9 @@ def format_search_results_ascii(
|
||||
if query:
|
||||
lines.append(f"Query: {query}")
|
||||
|
||||
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
summary = (
|
||||
f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
)
|
||||
lines.append(_apply_style(summary, ANSI_DIM, color))
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -17,18 +17,20 @@ from httpx._types import (
|
||||
)
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
from basic_memory.project_resolver import ProjectResolver
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import generate_permalink, normalize_project_reference
|
||||
|
||||
|
||||
async def resolve_project_parameter(
|
||||
project: Optional[str] = None,
|
||||
allow_discovery: bool = False,
|
||||
cloud_mode: Optional[bool] = None,
|
||||
default_project_mode: Optional[bool] = None,
|
||||
default_project: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve project parameter using unified linear priority chain.
|
||||
@@ -37,37 +39,28 @@ async def resolve_project_parameter(
|
||||
New code should consider using ProjectResolver directly for more detailed
|
||||
resolution information.
|
||||
|
||||
Resolution order (same for local and cloud modes):
|
||||
Resolution order:
|
||||
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT: project parameter passed directly
|
||||
3. DEFAULT: default project when default_project_mode=true
|
||||
4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE
|
||||
3. DEFAULT: default_project from config (if set)
|
||||
4. Fallback: discovery (if allowed) → NONE
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None in cloud mode for discovery mode
|
||||
allow_discovery: If True, allows returning None for discovery mode
|
||||
(used by tools like recent_activity that can operate across all projects)
|
||||
cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager.
|
||||
default_project_mode: Optional explicit default project mode. If not provided, reads from ConfigManager.
|
||||
default_project: Optional explicit default project. If not provided, reads from ConfigManager.
|
||||
|
||||
Returns:
|
||||
Resolved project name or None if no resolution possible
|
||||
"""
|
||||
# Load config for any values not explicitly provided
|
||||
if cloud_mode is None or default_project_mode is None or default_project is None:
|
||||
if default_project is None:
|
||||
config = ConfigManager().config
|
||||
if cloud_mode is None:
|
||||
cloud_mode = config.cloud_mode
|
||||
if default_project_mode is None:
|
||||
default_project_mode = config.default_project_mode
|
||||
if default_project is None:
|
||||
default_project = config.default_project
|
||||
default_project = config.default_project
|
||||
|
||||
# Create resolver with configuration and resolve
|
||||
resolver = ProjectResolver.from_env(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
)
|
||||
result = resolver.resolve(project=project, allow_discovery=allow_discovery)
|
||||
@@ -83,6 +76,100 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
|
||||
return [project.name for project in project_list.projects]
|
||||
|
||||
|
||||
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
|
||||
"""Return True when identifier matches workspace tenant_id or name."""
|
||||
if workspace.tenant_id == identifier:
|
||||
return True
|
||||
return workspace.name.lower() == identifier.lower()
|
||||
|
||||
|
||||
def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
|
||||
"""Format deterministic workspace choices for prompt-style errors."""
|
||||
return "\n".join(
|
||||
[
|
||||
(
|
||||
f"- {item.name} "
|
||||
f"(type={item.workspace_type}, role={item.role}, tenant_id={item.tenant_id})"
|
||||
)
|
||||
for item in workspaces
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
|
||||
"""Load available cloud workspaces for the current authenticated user."""
|
||||
if context:
|
||||
cached_workspaces = context.get_state("available_workspaces")
|
||||
if isinstance(cached_workspaces, list) and all(
|
||||
isinstance(item, WorkspaceInfo) for item in cached_workspaces
|
||||
):
|
||||
return cached_workspaces
|
||||
|
||||
from basic_memory.mcp.async_client import get_cloud_control_plane_client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
async with get_cloud_control_plane_client() as client:
|
||||
response = await call_get(client, "/workspaces/")
|
||||
workspace_list = WorkspaceListResponse.model_validate(response.json())
|
||||
|
||||
if context:
|
||||
context.set_state("available_workspaces", workspace_list.workspaces)
|
||||
|
||||
return workspace_list.workspaces
|
||||
|
||||
|
||||
async def resolve_workspace_parameter(
|
||||
workspace: Optional[str] = None,
|
||||
context: Optional[Context] = None,
|
||||
) -> WorkspaceInfo:
|
||||
"""Resolve workspace using explicit input, session cache, and cloud discovery."""
|
||||
if context:
|
||||
cached_workspace = context.get_state("active_workspace")
|
||||
if isinstance(cached_workspace, WorkspaceInfo) and (
|
||||
workspace is None or _workspace_matches_identifier(cached_workspace, workspace)
|
||||
):
|
||||
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
|
||||
return cached_workspace
|
||||
|
||||
workspaces = await get_available_workspaces(context=context)
|
||||
if not workspaces:
|
||||
raise ValueError(
|
||||
"No accessible workspaces found for this account. "
|
||||
"Ensure you have an active subscription and tenant access."
|
||||
)
|
||||
|
||||
selected_workspace: WorkspaceInfo | None = None
|
||||
|
||||
if workspace:
|
||||
matches = [item for item in workspaces if _workspace_matches_identifier(item, workspace)]
|
||||
if not matches:
|
||||
raise ValueError(
|
||||
f"Workspace '{workspace}' was not found.\n"
|
||||
f"Available workspaces:\n{_workspace_choices(workspaces)}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"Workspace name '{workspace}' matches multiple workspaces. "
|
||||
"Use tenant_id instead.\n"
|
||||
f"Available workspaces:\n{_workspace_choices(workspaces)}"
|
||||
)
|
||||
selected_workspace = matches[0]
|
||||
elif len(workspaces) == 1:
|
||||
selected_workspace = workspaces[0]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Multiple workspaces are available. Ask the user which workspace to use, then retry "
|
||||
"with the 'workspace' argument set to the tenant_id or unique name.\n"
|
||||
f"Available workspaces:\n{_workspace_choices(workspaces)}"
|
||||
)
|
||||
|
||||
if context:
|
||||
context.set_state("active_workspace", selected_workspace)
|
||||
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
|
||||
|
||||
return selected_workspace
|
||||
|
||||
|
||||
async def get_active_project(
|
||||
client: AsyncClient,
|
||||
project: Optional[str] = None,
|
||||
@@ -111,7 +198,7 @@ async def get_active_project(
|
||||
project_names = await get_project_names(client, headers)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
||||
"Either set 'default_project' in config, or use 'project' argument.\n"
|
||||
f"Available projects: {project_names}"
|
||||
)
|
||||
|
||||
@@ -150,6 +237,96 @@ async def get_active_project(
|
||||
return active_project
|
||||
|
||||
|
||||
def _split_project_prefix(path: str) -> tuple[Optional[str], str]:
|
||||
"""Split a possible project prefix from a memory URL path."""
|
||||
if "/" not in path:
|
||||
return None, path
|
||||
|
||||
project_prefix, remainder = path.split("/", 1)
|
||||
if not project_prefix or not remainder:
|
||||
return None, path
|
||||
|
||||
if "*" in project_prefix:
|
||||
return None, path
|
||||
|
||||
return project_prefix, remainder
|
||||
|
||||
|
||||
async def resolve_project_and_path(
|
||||
client: AsyncClient,
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
context: Optional[Context] = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
) -> tuple[ProjectItem, str, bool]:
|
||||
"""Resolve project and normalized path for memory:// identifiers.
|
||||
|
||||
Returns:
|
||||
Tuple of (active_project, normalized_path, is_memory_url)
|
||||
"""
|
||||
is_memory_url = identifier.strip().startswith("memory://")
|
||||
if not is_memory_url:
|
||||
active_project = await get_active_project(client, project, context, headers)
|
||||
return active_project, identifier, False
|
||||
|
||||
normalized_path = normalize_project_reference(memory_url_path(identifier))
|
||||
project_prefix, remainder = _split_project_prefix(normalized_path)
|
||||
include_project = ConfigManager().config.permalinks_include_project
|
||||
|
||||
# Trigger: memory URL begins with a potential project segment
|
||||
# Why: allow project-scoped memory URLs without requiring a separate project parameter
|
||||
# Outcome: attempt to resolve the prefix as a project and route to it
|
||||
if project_prefix:
|
||||
try:
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
|
||||
response = await call_post(
|
||||
client,
|
||||
"/v2/projects/resolve",
|
||||
json={"identifier": project_prefix},
|
||||
headers=headers,
|
||||
)
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
except ToolError as exc:
|
||||
if "project not found" not in str(exc).lower():
|
||||
raise
|
||||
else:
|
||||
resolved_project = await resolve_project_parameter(project_prefix)
|
||||
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
|
||||
project_prefix
|
||||
):
|
||||
raise ValueError(
|
||||
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
|
||||
)
|
||||
|
||||
active_project = ProjectItem(
|
||||
id=resolved.project_id,
|
||||
external_id=resolved.external_id,
|
||||
name=resolved.name,
|
||||
path=resolved.path,
|
||||
is_default=resolved.is_default,
|
||||
)
|
||||
if context:
|
||||
context.set_state("active_project", active_project)
|
||||
|
||||
resolved_path = f"{resolved.permalink}/{remainder}" if include_project else remainder
|
||||
return active_project, resolved_path, True
|
||||
|
||||
# Trigger: no resolvable project prefix in the memory URL
|
||||
# Why: preserve existing memory URL behavior within the active project
|
||||
# Outcome: use the active project and normalize the path for lookup
|
||||
active_project = await get_active_project(client, project, context, headers)
|
||||
resolved_path = normalized_path
|
||||
if include_project:
|
||||
# Trigger: project-prefixed permalinks are enabled and the path lacks a prefix
|
||||
# Why: ensure memory URL lookups align with canonical permalinks
|
||||
# Outcome: prefix the path with the active project's permalink
|
||||
project_prefix = active_project.permalink
|
||||
if resolved_path != project_prefix and not resolved_path.startswith(f"{project_prefix}/"):
|
||||
resolved_path = f"{project_prefix}/{resolved_path}"
|
||||
return active_project, resolved_path, True
|
||||
|
||||
|
||||
def add_project_metadata(result: str, project_name: str) -> str:
|
||||
"""Add project context as metadata footer for assistant session tracking.
|
||||
|
||||
@@ -169,6 +346,7 @@ def add_project_metadata(result: str, project_name: str) -> str:
|
||||
@asynccontextmanager
|
||||
async def get_project_client(
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Optional[Context] = None,
|
||||
) -> AsyncIterator[Tuple[AsyncClient, ProjectItem]]:
|
||||
"""Resolve project, create correctly-routed client, and validate project.
|
||||
@@ -180,6 +358,7 @@ async def get_project_client(
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
workspace: Optional cloud workspace selector (tenant_id or unique name)
|
||||
context: Optional FastMCP context for caching
|
||||
|
||||
Yields:
|
||||
@@ -200,12 +379,32 @@ async def get_project_client(
|
||||
project_names = await get_project_names(client)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
||||
"Either set 'default_project' in config, or use 'project' argument.\n"
|
||||
f"Available projects: {project_names}"
|
||||
)
|
||||
|
||||
# Step 2: Resolve project mode and optional workspace selection
|
||||
config = ConfigManager().config
|
||||
project_mode = config.get_project_mode(resolved_project)
|
||||
active_workspace: WorkspaceInfo | None = None
|
||||
|
||||
# Trigger: workspace provided for a local project
|
||||
# Why: workspace selection is a cloud routing concern only
|
||||
# Outcome: fail fast with a deterministic guidance message
|
||||
if project_mode != ProjectMode.CLOUD and workspace is not None:
|
||||
raise ValueError(
|
||||
f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. "
|
||||
"Workspace selection is only supported for cloud-mode projects."
|
||||
)
|
||||
|
||||
if project_mode == ProjectMode.CLOUD:
|
||||
active_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
|
||||
|
||||
# Step 2: Create client routed based on project's mode
|
||||
async with get_client(project_name=resolved_project) as client:
|
||||
async with get_client(
|
||||
project_name=resolved_project,
|
||||
workspace=active_workspace.tenant_id if active_workspace else None,
|
||||
) as client:
|
||||
# Step 3: Validate project exists via API
|
||||
active_project = await get_active_project(client, resolved_project, context)
|
||||
yield client, active_project
|
||||
|
||||
@@ -14,8 +14,8 @@ def ai_assistant_guide() -> str:
|
||||
"""Return a concise guide on Basic Memory tools and how to use them.
|
||||
|
||||
Dynamically adapts instructions based on configuration:
|
||||
- Default project mode: Simplified instructions with automatic project
|
||||
- Regular mode: Project discovery and selection guidance
|
||||
- Default project set: Simplified instructions with automatic project fallback
|
||||
- No default project: Project discovery and selection guidance
|
||||
- CLI constraint mode: Single project constraint information
|
||||
|
||||
Returns:
|
||||
@@ -30,34 +30,32 @@ def ai_assistant_guide() -> str:
|
||||
# Check configuration for mode-specific instructions
|
||||
config = ConfigManager().config
|
||||
|
||||
# Add mode-specific header
|
||||
mode_info = ""
|
||||
if config.default_project_mode:
|
||||
# Add mode-specific header based on whether a default project is configured
|
||||
if config.default_project:
|
||||
mode_info = f"""
|
||||
# 🎯 Default Project Mode Active
|
||||
# Default Project Active
|
||||
|
||||
**Current Configuration**: All operations automatically use project '{config.default_project}'
|
||||
**Current Configuration**: Operations automatically fall back to project '{config.default_project}'
|
||||
|
||||
**Simplified Usage**: You don't need to specify the project parameter in tool calls.
|
||||
- `write_note(title="Note", content="...", folder="docs")` ✅
|
||||
- Project parameter is optional and will default to '{config.default_project}'
|
||||
- `write_note(title="Note", content="...", folder="docs")` - uses '{config.default_project}'
|
||||
- To use a different project, explicitly specify: `project="other-project"`
|
||||
|
||||
────────────────────────────────────────
|
||||
---
|
||||
|
||||
"""
|
||||
else: # pragma: no cover
|
||||
mode_info = """
|
||||
# 🔧 Multi-Project Mode Active
|
||||
# Multi-Project Mode
|
||||
|
||||
**Current Configuration**: Project parameter required for all operations
|
||||
**Current Configuration**: No default project set — project parameter required for all operations
|
||||
|
||||
**Project Discovery Required**: Use these tools to select a project:
|
||||
- `list_memory_projects()` - See all available projects
|
||||
- `recent_activity()` - Get project activity and recommendations
|
||||
- Remember the user's project choice throughout the conversation
|
||||
|
||||
────────────────────────────────────────
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
@@ -65,6 +63,7 @@ def ai_assistant_guide() -> str:
|
||||
enhanced_content = mode_info + content
|
||||
|
||||
logger.info(
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) with mode: {'default_project' if config.default_project_mode else 'multi_project'}"
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) "
|
||||
f"with default_project: {config.default_project or 'none'}"
|
||||
)
|
||||
return enhanced_content
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""MCP resources for Basic Memory."""
|
||||
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
from basic_memory.mcp.resources.ui import (
|
||||
note_preview_ui,
|
||||
note_preview_ui_mcp_ui,
|
||||
note_preview_ui_tool_ui,
|
||||
note_preview_ui_vanilla,
|
||||
search_results_ui,
|
||||
search_results_ui_mcp_ui,
|
||||
search_results_ui_tool_ui,
|
||||
search_results_ui_vanilla,
|
||||
)
|
||||
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# from basic_memory.mcp.resources.ui import (
|
||||
# note_preview_ui,
|
||||
# note_preview_ui_mcp_ui,
|
||||
# note_preview_ui_tool_ui,
|
||||
# note_preview_ui_vanilla,
|
||||
# search_results_ui,
|
||||
# search_results_ui_mcp_ui,
|
||||
# search_results_ui_tool_ui,
|
||||
# search_results_ui_vanilla,
|
||||
# )
|
||||
|
||||
__all__ = [
|
||||
"project_info",
|
||||
"note_preview_ui",
|
||||
"note_preview_ui_mcp_ui",
|
||||
"note_preview_ui_tool_ui",
|
||||
"note_preview_ui_vanilla",
|
||||
"search_results_ui",
|
||||
"search_results_ui_mcp_ui",
|
||||
"search_results_ui_tool_ui",
|
||||
"search_results_ui_vanilla",
|
||||
# "note_preview_ui",
|
||||
# "note_preview_ui_mcp_ui",
|
||||
# "note_preview_ui_tool_ui",
|
||||
# "note_preview_ui_vanilla",
|
||||
# "search_results_ui",
|
||||
# "search_results_ui_mcp_ui",
|
||||
# "search_results_ui_tool_ui",
|
||||
# "search_results_ui_vanilla",
|
||||
]
|
||||
|
||||
@@ -14,36 +14,30 @@ Basic Memory creates a semantic knowledge graph from markdown files. Focus on bu
|
||||
|
||||
**Your role**: You're helping humans build enduring knowledge they'll own forever. The semantic graph (observations, relations, context) helps you provide better assistance by understanding connections and maintaining continuity. Think: lasting insights worth keeping, not disposable chat logs.
|
||||
|
||||
## Project Management
|
||||
## Project Management
|
||||
|
||||
All tools require explicit project specification.
|
||||
|
||||
**Three-tier resolution:**
|
||||
1. CLI constraint: `--project name` (highest priority)
|
||||
**Resolution priority:**
|
||||
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
|
||||
2. Explicit parameter: `project="name"` in tool calls
|
||||
3. Default mode: `default_project_mode=true` in config (fallback)
|
||||
3. Default project: `default_project` in config (fallback)
|
||||
|
||||
### Quick Setup Check
|
||||
|
||||
```python
|
||||
# Discover projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
# Check if default_project_mode enabled
|
||||
# If yes: project parameter optional
|
||||
# If no: project parameter required
|
||||
```
|
||||
|
||||
### Default Project Mode
|
||||
### Default Project
|
||||
|
||||
When `default_project_mode=true`:
|
||||
When `default_project` is set in config:
|
||||
```python
|
||||
# These are equivalent:
|
||||
await write_note("Note", "Content", "folder")
|
||||
await write_note("Note", "Content", "folder", project="main")
|
||||
```
|
||||
|
||||
When `default_project_mode=false`:
|
||||
When no `default_project` is configured:
|
||||
```python
|
||||
# Project required:
|
||||
await write_note("Note", "Content", "folder", project="main") # ✓
|
||||
@@ -59,7 +53,7 @@ await write_note(
|
||||
title="Topic",
|
||||
content="# Topic\n## Observations\n- [category] fact\n## Relations\n- relates_to [[Other]]",
|
||||
folder="notes",
|
||||
project="main" # Required unless default_project_mode=true
|
||||
project="main" # Optional if default_project is set in config
|
||||
)
|
||||
```
|
||||
|
||||
@@ -143,12 +137,11 @@ await write_note(
|
||||
### 1. Project Management
|
||||
|
||||
**Single-project users:**
|
||||
- Enable `default_project_mode=true`
|
||||
- Simpler tool calls
|
||||
- Set `default_project` in config (e.g., `"main"`)
|
||||
- Simpler tool calls — project parameter is optional
|
||||
|
||||
**Multi-project users:**
|
||||
- Keep `default_project_mode=false`
|
||||
- Always specify project explicitly
|
||||
- Always specify project explicitly in tool calls
|
||||
|
||||
**Discovery:**
|
||||
```python
|
||||
@@ -200,7 +193,7 @@ Background information
|
||||
**Missing project:**
|
||||
```python
|
||||
try:
|
||||
await search_notes(query="test") # Missing project parameter - will error
|
||||
await search_notes(query="test") # Fails if no default_project configured
|
||||
except:
|
||||
# Show available projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
@@ -38,8 +38,8 @@ async def project_info(
|
||||
|
||||
Args:
|
||||
project: Optional project name. If not provided, uses default_project
|
||||
(if default_project_mode=true) or CLI constraint. If unknown,
|
||||
use list_memory_projects() to discover available projects.
|
||||
from config or CLI constraint. If unknown, use
|
||||
list_memory_projects() to discover available projects.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Basic Memory FastMCP server.
|
||||
"""
|
||||
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.mcp.container import McpContainer, set_container
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
|
||||
@@ -26,7 +28,39 @@ async def lifespan(app: FastMCP):
|
||||
container = McpContainer.create()
|
||||
set_container(container)
|
||||
|
||||
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
config = container.config
|
||||
logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
logger.info(
|
||||
f"Config: database_backend={config.database_backend.value}, "
|
||||
f"semantic_search_enabled={config.semantic_search_enabled}, "
|
||||
f"default_project={config.default_project}"
|
||||
)
|
||||
if config.semantic_search_enabled:
|
||||
logger.info(
|
||||
f"Semantic search: provider={config.semantic_embedding_provider}, "
|
||||
f"model={config.semantic_embedding_model}, "
|
||||
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
|
||||
f"batch_size={config.semantic_embedding_batch_size}"
|
||||
)
|
||||
|
||||
# Log configured projects with their routing mode
|
||||
for name, entry in config.projects.items():
|
||||
default = " (default)" if name == config.default_project else ""
|
||||
logger.info(f"Project: {name} -> {entry.path} [mode={entry.mode.value}]{default}")
|
||||
|
||||
# Check cloud auth status (local file check, no network call)
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
tokens = auth.load_tokens()
|
||||
if tokens is not None:
|
||||
if not auth.is_token_valid(tokens):
|
||||
expires_at = tokens.get("expires_at", 0)
|
||||
expired_ago = int(time.time() - expires_at)
|
||||
logger.warning(f"Cloud token expired {expired_ago}s ago - may need 'bm cloud login'")
|
||||
else:
|
||||
logger.info("Cloud: authenticated (OAuth token valid)")
|
||||
|
||||
if config.cloud_api_key:
|
||||
logger.info("Cloud: API key configured")
|
||||
|
||||
# Track if we created the engine (vs test fixtures providing it)
|
||||
# This prevents disposing an engine provided by test fixtures when
|
||||
|
||||
@@ -11,7 +11,9 @@ from basic_memory.mcp.tools.read_content import read_content
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.cloud_info import cloud_info
|
||||
@@ -21,6 +23,7 @@ from basic_memory.mcp.tools.canvas import canvas
|
||||
from basic_memory.mcp.tools.list_directory import list_directory
|
||||
from basic_memory.mcp.tools.edit_note import edit_note
|
||||
from basic_memory.mcp.tools.move_note import move_note
|
||||
from basic_memory.mcp.tools.workspaces import list_workspaces
|
||||
from basic_memory.mcp.tools.project_management import (
|
||||
list_memory_projects,
|
||||
create_memory_project,
|
||||
@@ -44,11 +47,12 @@ __all__ = [
|
||||
"fetch",
|
||||
"list_directory",
|
||||
"list_memory_projects",
|
||||
"list_workspaces",
|
||||
"move_note",
|
||||
"read_content",
|
||||
"read_note",
|
||||
"release_notes",
|
||||
"read_note_ui",
|
||||
# "read_note_ui",
|
||||
"recent_activity",
|
||||
"schema_diff",
|
||||
"schema_infer",
|
||||
@@ -56,7 +60,7 @@ __all__ = [
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
"search_notes_ui",
|
||||
# "search_notes_ui",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -1,19 +1,176 @@
|
||||
"""Build context tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
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.mcp.server import mcp
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
ContextResult,
|
||||
EntitySummary,
|
||||
GraphContext,
|
||||
MemoryUrl,
|
||||
memory_url_path,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
)
|
||||
|
||||
# --- Fields to strip from each model (redundant with parent entity) ---
|
||||
|
||||
_OBSERVATION_STRIP = {
|
||||
"observation_id",
|
||||
"entity_id",
|
||||
"entity_external_id",
|
||||
"title",
|
||||
"file_path",
|
||||
"created_at",
|
||||
}
|
||||
_RELATION_STRIP = {
|
||||
"relation_id",
|
||||
"entity_id",
|
||||
"from_entity_id",
|
||||
"from_entity_external_id",
|
||||
"to_entity_id",
|
||||
"to_entity_external_id",
|
||||
"title",
|
||||
"file_path",
|
||||
"created_at",
|
||||
}
|
||||
_ENTITY_STRIP = {"entity_id", "created_at"}
|
||||
_METADATA_STRIP = {"total_results", "generated_at"}
|
||||
|
||||
|
||||
def _slim_summary(summary: EntitySummary | RelationSummary | ObservationSummary) -> dict:
|
||||
"""Strip redundant fields from a summary model based on its type."""
|
||||
if isinstance(summary, ObservationSummary):
|
||||
strip = _OBSERVATION_STRIP
|
||||
elif isinstance(summary, RelationSummary):
|
||||
strip = _RELATION_STRIP
|
||||
else:
|
||||
strip = _ENTITY_STRIP
|
||||
|
||||
data = summary.model_dump()
|
||||
for key in strip:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
|
||||
|
||||
def _slim_context(graph: GraphContext) -> dict:
|
||||
"""Transform GraphContext into a slimmed dict, stripping redundant fields.
|
||||
|
||||
Reduces payload size ~40% by removing fields on nested objects that
|
||||
duplicate information already present on the parent entity (IDs,
|
||||
timestamps, file paths).
|
||||
"""
|
||||
slimmed_results = []
|
||||
for result in graph.results:
|
||||
slimmed_results.append(
|
||||
{
|
||||
"primary_result": _slim_summary(result.primary_result),
|
||||
"observations": [_slim_summary(obs) for obs in result.observations],
|
||||
"related_results": [_slim_summary(rel) for rel in result.related_results],
|
||||
}
|
||||
)
|
||||
|
||||
metadata = graph.metadata.model_dump()
|
||||
for key in _METADATA_STRIP:
|
||||
metadata.pop(key, None)
|
||||
|
||||
return {
|
||||
"results": slimmed_results,
|
||||
"metadata": metadata,
|
||||
"page": graph.page,
|
||||
"page_size": graph.page_size,
|
||||
}
|
||||
|
||||
|
||||
def _format_entity_block(result: ContextResult) -> str:
|
||||
"""Format a single context result as a markdown block."""
|
||||
primary = result.primary_result
|
||||
lines = []
|
||||
|
||||
# --- Header ---
|
||||
lines.append(f"## {primary.title}")
|
||||
if primary.permalink:
|
||||
lines.append(f"permalink: {primary.permalink}")
|
||||
# RelationSummary has no content field; Entity/Observation do
|
||||
if not isinstance(primary, RelationSummary) and primary.content:
|
||||
lines.append("")
|
||||
lines.append(primary.content)
|
||||
|
||||
# --- Observations ---
|
||||
if result.observations:
|
||||
lines.append("")
|
||||
lines.append("### Observations")
|
||||
for obs in result.observations:
|
||||
lines.append(f"- [{obs.category}] {obs.content}")
|
||||
|
||||
# --- Relations (from primary's related_results that are RelationSummary) ---
|
||||
relation_items: list[RelationSummary] = [
|
||||
r for r in result.related_results if isinstance(r, RelationSummary)
|
||||
]
|
||||
if relation_items:
|
||||
lines.append("")
|
||||
lines.append("### Relations")
|
||||
for rel in relation_items:
|
||||
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
|
||||
|
||||
# --- Related entities (non-relation related results) ---
|
||||
related_entities: list[EntitySummary | ObservationSummary] = [
|
||||
r for r in result.related_results if not isinstance(r, RelationSummary)
|
||||
]
|
||||
if related_entities:
|
||||
lines.append("")
|
||||
lines.append("### Related")
|
||||
for item in related_entities:
|
||||
permalink = item.permalink if item.permalink else ""
|
||||
lines.append(f"- [[{item.title}]] ({permalink})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_context_markdown(graph: GraphContext, project: str) -> str:
|
||||
"""Format GraphContext as compact markdown text.
|
||||
|
||||
Produces a human-readable markdown representation that is much smaller
|
||||
than the equivalent JSON, suitable for LLM consumption when structured
|
||||
data isn't needed.
|
||||
"""
|
||||
if not graph.results:
|
||||
uri = graph.metadata.uri or ""
|
||||
return f"No results found for '{uri}' in project '{project}'."
|
||||
|
||||
parts = []
|
||||
|
||||
# --- Title from first primary result ---
|
||||
first_title = graph.results[0].primary_result.title
|
||||
if len(graph.results) == 1:
|
||||
parts.append(f"# Context: {first_title}")
|
||||
else:
|
||||
uri = graph.metadata.uri or ""
|
||||
parts.append(f"# Context: {uri}")
|
||||
|
||||
parts.append("")
|
||||
|
||||
# --- Entity blocks separated by --- ---
|
||||
entity_blocks = [_format_entity_block(result) for result in graph.results]
|
||||
parts.append("\n\n---\n\n".join(entity_blocks))
|
||||
|
||||
# --- Footer ---
|
||||
meta = graph.metadata
|
||||
primary_count = meta.primary_count or 0
|
||||
related_count = meta.related_count or 0
|
||||
parts.append("")
|
||||
parts.append("---")
|
||||
parts.append(
|
||||
f"*{primary_count} primary, {related_count} related"
|
||||
f" | depth={meta.depth} | project: {project}*"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="""Build context from a memory:// URI to continue conversations naturally.
|
||||
@@ -30,18 +187,24 @@ from basic_memory.schemas.memory import (
|
||||
Timeframes support natural language like:
|
||||
- "2 days ago", "last week", "today", "3 months ago"
|
||||
- Or standard formats like "7d", "24h"
|
||||
|
||||
Format options:
|
||||
- "json" (default): Slimmed JSON with redundant fields removed
|
||||
- "text": Compact markdown text for LLM consumption
|
||||
""",
|
||||
)
|
||||
async def build_context(
|
||||
url: MemoryUrl,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
depth: str | int | None = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
output_format: Literal["json", "text"] = "json",
|
||||
context: Context | None = None,
|
||||
) -> GraphContext:
|
||||
) -> dict | str:
|
||||
"""Get context needed to continue a discussion within a specific project.
|
||||
|
||||
This tool enables natural continuation of discussions by loading relevant context
|
||||
@@ -62,13 +225,13 @@ async def build_context(
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
output_format: Response format - "json" for slimmed JSON dict,
|
||||
"text" for compact markdown text
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
- primary_results: Content matching the memory:// URI
|
||||
- related_results: Connected content via relations
|
||||
- metadata: Context building details
|
||||
dict (output_format="json"): Slimmed JSON with redundant fields removed
|
||||
str (output_format="text"): Compact markdown representation
|
||||
|
||||
Examples:
|
||||
# Continue a specific discussion
|
||||
@@ -77,11 +240,8 @@ async def build_context(
|
||||
# Get deeper context about a component
|
||||
build_context("work-docs", "memory://components/memory-service", depth=2)
|
||||
|
||||
# Look at recent changes to a specification
|
||||
build_context("research", "memory://specs/document-format", timeframe="today")
|
||||
|
||||
# Research the history of a feature
|
||||
build_context("dev-notes", "memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
# Get text output for compact context
|
||||
build_context("research", "memory://specs/search", output_format="text")
|
||||
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
@@ -99,17 +259,25 @@ async def build_context(
|
||||
|
||||
# URL is already validated and normalized by MemoryUrl type annotation
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Resolve memory:// identifier with project-prefix awareness
|
||||
_, resolved_path, _ = await resolve_project_and_path(client, url, project, context)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import MemoryClient
|
||||
|
||||
# Use typed MemoryClient for API calls
|
||||
memory_client = MemoryClient(client, active_project.external_id)
|
||||
return await memory_client.build_context(
|
||||
memory_url_path(url),
|
||||
graph = await memory_client.build_context(
|
||||
resolved_path,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
|
||||
if output_format == "text":
|
||||
return _format_context_markdown(graph, active_project.name)
|
||||
|
||||
return _slim_context(graph)
|
||||
|
||||
@@ -23,6 +23,7 @@ async def canvas(
|
||||
title: str,
|
||||
directory: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Create an Obsidian canvas file with the provided nodes and edges.
|
||||
@@ -93,7 +94,7 @@ async def canvas(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
file_path = f"{directory}/{file_title}"
|
||||
|
||||
@@ -13,22 +13,41 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult
|
||||
|
||||
|
||||
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
|
||||
def _format_search_results_for_chatgpt(
|
||||
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format search results according to ChatGPT's expected schema.
|
||||
|
||||
Returns a list of result objects with id, title, and url fields.
|
||||
"""
|
||||
if isinstance(results, SearchResponse):
|
||||
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
|
||||
elif isinstance(results, dict):
|
||||
nested_results = results.get("results")
|
||||
raw_results = nested_results if isinstance(nested_results, list) else []
|
||||
else:
|
||||
raw_results = results
|
||||
|
||||
formatted_results = []
|
||||
|
||||
for result in results.results:
|
||||
for result in raw_results:
|
||||
if isinstance(result, SearchResult):
|
||||
title = result.title
|
||||
permalink = result.permalink
|
||||
elif isinstance(result, dict):
|
||||
title = result.get("title")
|
||||
permalink = result.get("permalink")
|
||||
else:
|
||||
raise TypeError(f"Unexpected result type: {type(result).__name__}")
|
||||
|
||||
formatted_result = {
|
||||
"id": result.permalink or f"doc-{len(formatted_results)}",
|
||||
"title": result.title if result.title and result.title.strip() else "Untitled",
|
||||
"url": result.permalink or "",
|
||||
"id": permalink or f"doc-{len(formatted_results)}",
|
||||
"title": title if isinstance(title, str) and title.strip() else "Untitled",
|
||||
"url": permalink or "",
|
||||
}
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
@@ -102,6 +121,7 @@ async def search(
|
||||
page=1,
|
||||
page_size=10, # Reasonable default for ChatGPT consumption
|
||||
search_type="text", # Default to full-text search
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
|
||||
@@ -115,10 +135,11 @@ async def search(
|
||||
}
|
||||
else:
|
||||
# Format successful results for ChatGPT
|
||||
formatted_results = _format_search_results_for_chatgpt(results)
|
||||
raw_results = results.get("results", []) if isinstance(results, dict) else []
|
||||
formatted_results = _format_search_results_for_chatgpt(raw_results)
|
||||
search_results = {
|
||||
"results": formatted_results,
|
||||
"total_count": len(results.results), # Use actual count from results
|
||||
"total_count": len(raw_results), # Use actual count from results
|
||||
"query": query,
|
||||
}
|
||||
logger.info(f"Search completed: {len(formatted_results)} results returned")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
@@ -151,8 +151,10 @@ async def delete_note(
|
||||
identifier: str,
|
||||
is_directory: bool = False,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> bool | str:
|
||||
) -> bool | str | dict:
|
||||
"""Delete a note or directory from the knowledge base.
|
||||
|
||||
Permanently removes a note or directory from the specified project. For single notes,
|
||||
@@ -173,6 +175,8 @@ async def delete_note(
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to delete from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
output_format: "text" preserves existing behavior (bool/string). "json"
|
||||
returns machine-readable deletion metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -215,7 +219,7 @@ async def delete_note(
|
||||
with suggestions for finding the correct identifier, including search
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.debug(
|
||||
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
|
||||
)
|
||||
@@ -230,6 +234,15 @@ async def delete_note(
|
||||
if is_directory:
|
||||
try:
|
||||
result = await knowledge_client.delete_directory(identifier)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
# Build success message for directory delete
|
||||
result_lines = [
|
||||
@@ -287,18 +300,41 @@ delete_note("path/to/file.md")
|
||||
```"""
|
||||
|
||||
# Handle single note deletes
|
||||
note_title = None
|
||||
note_permalink = None
|
||||
note_file_path = None
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
if output_format == "json":
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
note_title = entity.title
|
||||
note_permalink = entity.permalink
|
||||
note_file_path = entity.file_path
|
||||
except ToolError as e:
|
||||
# If entity not found, return False (note doesn't exist)
|
||||
if "Entity not found" in str(e) or "not found" in str(e).lower():
|
||||
logger.warning(f"Note not found for deletion: {identifier}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
}
|
||||
return False
|
||||
# For other resolution errors, return formatted error message
|
||||
logger.error( # pragma: no cover
|
||||
f"Delete failed for '{identifier}': {e}, project: {active_project.name}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"error": str(e),
|
||||
}
|
||||
return _format_delete_error_response( # pragma: no cover
|
||||
active_project.name, str(e), identifier
|
||||
)
|
||||
@@ -311,14 +347,36 @@ delete_note("path/to/file.md")
|
||||
logger.info(
|
||||
f"Successfully deleted note: {identifier} in project: {active_project.name}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": True,
|
||||
"title": note_title,
|
||||
"permalink": note_permalink,
|
||||
"file_path": note_file_path,
|
||||
}
|
||||
return True
|
||||
else:
|
||||
logger.warning( # pragma: no cover
|
||||
f"Delete operation completed but note was not deleted: {identifier}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": False,
|
||||
"title": note_title,
|
||||
"permalink": note_permalink,
|
||||
"file_path": note_file_path,
|
||||
}
|
||||
return False # pragma: no cover
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": False,
|
||||
"title": note_title,
|
||||
"permalink": note_permalink,
|
||||
"file_path": note_file_path,
|
||||
"error": str(e),
|
||||
}
|
||||
# Return formatted error message for better user experience
|
||||
return _format_delete_error_response(active_project.name, str(e), identifier)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Edit note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
@@ -131,11 +131,13 @@ async def edit_note(
|
||||
operation: str,
|
||||
content: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
) -> str | dict:
|
||||
"""Edit an existing markdown note in the knowledge base.
|
||||
|
||||
Makes targeted changes to existing notes without rewriting the entire content.
|
||||
@@ -159,6 +161,8 @@ async def edit_note(
|
||||
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
||||
find_text: For find_replace operation - the text to find and replace
|
||||
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
||||
output_format: "text" returns the existing markdown summary. "json" returns
|
||||
machine-readable edit metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -211,7 +215,7 @@ async def edit_note(
|
||||
search_notes() first to find the correct identifier. The tool provides detailed
|
||||
error messages with suggestions if operations fail.
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
||||
|
||||
# Validate operation
|
||||
@@ -310,11 +314,29 @@ async def edit_note(
|
||||
relations_count=len(result.relations),
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"operation": operation,
|
||||
}
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing note: {e}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"error": str(e),
|
||||
}
|
||||
return _format_error_response(
|
||||
str(e), operation, identifier, find_text, expected_replacements, active_project.name
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ async def list_directory(
|
||||
depth: int = 1,
|
||||
file_name_glob: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""List directory contents from the knowledge base with optional filtering.
|
||||
@@ -61,7 +62,7 @@ async def list_directory(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.debug(
|
||||
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Move note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
@@ -348,8 +348,10 @@ async def move_note(
|
||||
destination_path: str,
|
||||
is_directory: bool = False,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
) -> str | dict:
|
||||
"""Move a note or directory to a new location within the same project.
|
||||
|
||||
Moves a note or directory from one location to another within the project,
|
||||
@@ -368,6 +370,8 @@ async def move_note(
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to move within. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
output_format: "text" returns existing markdown guidance/success text. "json"
|
||||
returns machine-readable move metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -411,7 +415,7 @@ async def move_note(
|
||||
- Re-indexes the entity for search
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.debug(
|
||||
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {active_project.name}"
|
||||
)
|
||||
@@ -424,6 +428,16 @@ async def move_note(
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"""# Move Failed - Security Validation Error
|
||||
|
||||
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
||||
@@ -447,6 +461,19 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
|
||||
try:
|
||||
result = await knowledge_client.move_directory(identifier, destination_path)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": result.failed_moves == 0,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"is_directory": True,
|
||||
"total_files": result.total_files,
|
||||
"successful_moves": result.successful_moves,
|
||||
"failed_moves": result.failed_moves,
|
||||
}
|
||||
|
||||
# Build success message for directory move
|
||||
result_lines = [
|
||||
@@ -488,6 +515,17 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
logger.error(
|
||||
f"Directory move failed for '{identifier}' to '{destination_path}': {e}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"is_directory": True,
|
||||
"error": str(e),
|
||||
}
|
||||
return f"""# Directory Move Failed
|
||||
|
||||
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
|
||||
@@ -512,6 +550,16 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
)
|
||||
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
|
||||
|
||||
# Import here to avoid circular import
|
||||
@@ -536,6 +584,16 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
# 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}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"error": "FILE_EXTENSION_REQUIRED",
|
||||
}
|
||||
return dedent(f"""
|
||||
# Move Failed - File Extension Required
|
||||
|
||||
@@ -572,6 +630,16 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
logger.warning(
|
||||
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": source_entity.title,
|
||||
"permalink": source_entity.permalink,
|
||||
"file_path": source_entity.file_path,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"error": "FILE_EXTENSION_MISMATCH",
|
||||
}
|
||||
return dedent(f"""
|
||||
# Move Failed - File Extension Mismatch
|
||||
|
||||
@@ -599,6 +667,15 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
|
||||
# Call the move API using KnowledgeClient
|
||||
result = await knowledge_client.move_entity(entity_id, destination_path)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": True,
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
}
|
||||
|
||||
# Build success message
|
||||
result_lines = [
|
||||
@@ -623,5 +700,15 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"error": str(e),
|
||||
}
|
||||
# Return formatted error message for better user experience
|
||||
return _format_move_error_response(str(e), identifier, destination_path)
|
||||
|
||||
@@ -5,6 +5,7 @@ and manage project context during conversations.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
@@ -14,65 +15,71 @@ from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@mcp.tool("list_memory_projects")
|
||||
async def list_memory_projects(context: Context | None = None) -> str:
|
||||
async def list_memory_projects(
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
"""List all available projects with their status.
|
||||
|
||||
Shows all Basic Memory projects that are available for MCP operations.
|
||||
Use this tool to discover projects when you need to know which project to use.
|
||||
|
||||
Use this tool:
|
||||
- At conversation start when project is unknown
|
||||
- When user asks about available projects
|
||||
- Before any operation requiring a project
|
||||
|
||||
After calling:
|
||||
- Ask user which project to use
|
||||
- Remember their choice for the session
|
||||
|
||||
Returns:
|
||||
Formatted list of projects with session management guidance
|
||||
|
||||
Example:
|
||||
list_memory_projects()
|
||||
Args:
|
||||
output_format: "text" returns the existing human-readable project list.
|
||||
"json" returns structured project metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
"""
|
||||
async with get_client() as client:
|
||||
if context: # pragma: no cover
|
||||
await context.info("Listing all available projects")
|
||||
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
project_list = await project_client.list_projects()
|
||||
|
||||
if output_format == "json":
|
||||
projects = [
|
||||
{
|
||||
"name": project.name,
|
||||
"path": project.path,
|
||||
"is_default": project.is_default,
|
||||
"is_private": False,
|
||||
"display_name": None,
|
||||
}
|
||||
for project in project_list.projects
|
||||
]
|
||||
return {
|
||||
"projects": projects,
|
||||
"default_project": project_list.default_project,
|
||||
"constrained_project": constrained_project,
|
||||
}
|
||||
|
||||
if constrained_project:
|
||||
result = f"Project: {constrained_project}\n\n"
|
||||
result += "Note: This MCP server is constrained to a single project.\n"
|
||||
result += "All operations will automatically use this project."
|
||||
else:
|
||||
# Show all projects with session guidance
|
||||
result = "Available projects:\n"
|
||||
return result
|
||||
|
||||
for project in project_list.projects:
|
||||
result += f"• {project.name}\n"
|
||||
|
||||
result += "\n" + "─" * 40 + "\n"
|
||||
result += "Next: Ask which project to use for this session.\n"
|
||||
result += "Example: 'Which project should I use for this task?'\n\n"
|
||||
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
|
||||
result += "The user can say 'switch to [project]' to change projects."
|
||||
result = "Available projects:\n"
|
||||
for project in project_list.projects:
|
||||
result += f"• {project.name}\n"
|
||||
|
||||
result += "\n" + "─" * 40 + "\n"
|
||||
result += "Next: Ask which project to use for this session.\n"
|
||||
result += "Example: 'Which project should I use for this task?'\n\n"
|
||||
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
|
||||
result += "The user can say 'switch to [project]' to change projects."
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool("create_memory_project")
|
||||
async def create_memory_project(
|
||||
project_name: str, project_path: str, set_default: bool = False, context: Context | None = None
|
||||
) -> str:
|
||||
project_name: str,
|
||||
project_path: str,
|
||||
set_default: bool = False,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
"""Create a new Basic Memory project.
|
||||
|
||||
Creates a new project with the specified name and path. The project directory
|
||||
@@ -82,6 +89,9 @@ async def create_memory_project(
|
||||
project_name: Name for the new project (must be unique)
|
||||
project_path: File system path where the project will be stored
|
||||
set_default: Whether to set this project as the default (optional, defaults to False)
|
||||
output_format: "text" returns the existing human-readable result text.
|
||||
"json" returns structured project creation metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
|
||||
Returns:
|
||||
Confirmation message with project details
|
||||
@@ -94,6 +104,19 @@ async def create_memory_project(
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
if constrained_project:
|
||||
if output_format == "json":
|
||||
return {
|
||||
"name": project_name,
|
||||
"path": project_path,
|
||||
"is_default": False,
|
||||
"created": False,
|
||||
"already_exists": False,
|
||||
"error": "PROJECT_CONSTRAINED",
|
||||
"message": (
|
||||
f"Project creation disabled - MCP server is constrained to project "
|
||||
f"'{constrained_project}'."
|
||||
),
|
||||
}
|
||||
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
|
||||
|
||||
if context: # pragma: no cover
|
||||
@@ -109,8 +132,46 @@ async def create_memory_project(
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
existing = await project_client.list_projects()
|
||||
existing_match = next(
|
||||
(p for p in existing.projects if p.name.casefold() == project_name.casefold()),
|
||||
None,
|
||||
)
|
||||
if existing_match:
|
||||
is_default = bool(
|
||||
existing_match.is_default or existing.default_project == existing_match.name
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"name": existing_match.name,
|
||||
"path": existing_match.path,
|
||||
"is_default": is_default,
|
||||
"created": False,
|
||||
"already_exists": True,
|
||||
}
|
||||
return (
|
||||
f"✓ Project already exists: {existing_match.name}\n\n"
|
||||
f"Project Details:\n"
|
||||
f"• Name: {existing_match.name}\n"
|
||||
f"• Path: {existing_match.path}\n"
|
||||
f"{'• Set as default project\\n' if is_default else ''}"
|
||||
"\nProject is already available for use in tool calls.\n"
|
||||
)
|
||||
|
||||
status_response = await project_client.create_project(project_request.model_dump())
|
||||
|
||||
if output_format == "json":
|
||||
new_project = status_response.new_project
|
||||
return {
|
||||
"name": new_project.name if new_project else project_name,
|
||||
"path": new_project.path if new_project else project_path,
|
||||
"is_default": bool(
|
||||
(new_project.is_default if new_project else False) or set_default
|
||||
),
|
||||
"created": True,
|
||||
"already_exists": False,
|
||||
}
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
if status_response.new_project:
|
||||
|
||||
@@ -15,7 +15,7 @@ from PIL import Image as PILImage
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
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.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
@@ -150,7 +150,10 @@ def optimize_image(img, content_length, max_output_bytes=350000):
|
||||
|
||||
@mcp.tool(description="Read a file's raw content by path or permalink")
|
||||
async def read_content(
|
||||
path: str, project: Optional[str] = None, context: Context | None = None
|
||||
path: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> dict:
|
||||
"""Read a file's raw content by path or permalink.
|
||||
|
||||
@@ -201,12 +204,18 @@ async def read_content(
|
||||
"""
|
||||
logger.info("Reading file", path=path, project=project)
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
url = memory_url_path(path)
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Resolve path with project-prefix awareness for memory:// URLs
|
||||
_, url, _ = await resolve_project_and_path(client, path, project, context)
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
# has a scheme prefix that confuses path validation)
|
||||
raw_path = memory_url_path(path) if path.startswith("memory://") else path
|
||||
project_path = active_project.home
|
||||
if not validate_project_path(url, project_path):
|
||||
if not validate_project_path(raw_path, project_path) or not validate_project_path(
|
||||
url, project_path
|
||||
):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
path=path,
|
||||
|
||||
@@ -3,29 +3,73 @@
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
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.mcp.server import mcp
|
||||
from basic_memory.mcp.formatting import format_note_preview_ascii
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
def _is_exact_title_match(identifier: str, title: str) -> bool:
|
||||
"""Return True when identifier exactly matches a title (case-insensitive)."""
|
||||
return identifier.strip().casefold() == title.strip().casefold()
|
||||
|
||||
|
||||
def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
|
||||
"""Parse opening YAML frontmatter and return (body, frontmatter).
|
||||
|
||||
Mirrors CLI behavior: only parses a frontmatter block at the very top.
|
||||
If parsing fails or frontmatter is not a mapping, returns body unchanged and None.
|
||||
"""
|
||||
original_content = content
|
||||
if not content.startswith("---\n"):
|
||||
return original_content, None
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
closing_index = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
closing_index = i
|
||||
break
|
||||
|
||||
if closing_index is None:
|
||||
return original_content, None
|
||||
|
||||
fm_text = "".join(lines[1:closing_index])
|
||||
try:
|
||||
parsed = yaml.safe_load(fm_text)
|
||||
except yaml.YAMLError:
|
||||
return original_content, None
|
||||
|
||||
if parsed is None:
|
||||
parsed = {}
|
||||
if not isinstance(parsed, dict):
|
||||
return original_content, None
|
||||
|
||||
body_content = "".join(lines[closing_index + 1 :])
|
||||
return body_content, parsed
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a markdown note by title or permalink.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
)
|
||||
async def read_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
include_frontmatter: bool = False,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
) -> str | dict:
|
||||
"""Return the raw markdown for a note, or guidance text if no match is found.
|
||||
|
||||
Finds and retrieves a note by its title, permalink, or content search,
|
||||
@@ -49,8 +93,10 @@ async def read_note(
|
||||
Can be a full memory:// URL, a permalink, a title, or search text
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
output_format: "default" returns markdown, "ascii" returns a plain text preview,
|
||||
"ansi" returns a colorized preview for TUI clients.
|
||||
output_format: "text" returns markdown content or guidance text.
|
||||
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
|
||||
include_frontmatter: When output_format="json", whether content should include the
|
||||
opening YAML frontmatter block.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -81,13 +127,18 @@ async def read_note(
|
||||
If the exact note isn't found, this tool provides helpful suggestions
|
||||
including related notes, search commands, and note creation templates.
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Resolve identifier with project-prefix awareness for memory:// URLs
|
||||
_, entity_path, _ = await resolve_project_and_path(client, identifier, project, context)
|
||||
|
||||
# Validate identifier to prevent path traversal attacks
|
||||
# We need to check both the raw identifier and the processed path
|
||||
processed_path = memory_url_path(identifier)
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
# has a scheme prefix that confuses path validation)
|
||||
raw_path = memory_url_path(identifier) if identifier.startswith("memory://") else identifier
|
||||
processed_path = entity_path
|
||||
project_path = active_project.home
|
||||
|
||||
if not validate_project_path(identifier, project_path) or not validate_project_path(
|
||||
if not validate_project_path(raw_path, project_path) or not validate_project_path(
|
||||
processed_path, project_path
|
||||
):
|
||||
logger.warning(
|
||||
@@ -96,10 +147,18 @@ async def read_note(
|
||||
processed_path=processed_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"content": None,
|
||||
"frontmatter": None,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Get the file via REST API - first try direct identifier resolution
|
||||
entity_path = memory_url_path(identifier)
|
||||
logger.info(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
@@ -111,9 +170,59 @@ async def read_note(
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
async def _read_json_payload(entity_id: str) -> dict:
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
content_text = response.text
|
||||
body_content, parsed_frontmatter = _parse_opening_frontmatter(content_text)
|
||||
return {
|
||||
"title": entity.title,
|
||||
"permalink": entity.permalink,
|
||||
"file_path": entity.file_path,
|
||||
"content": content_text if include_frontmatter else body_content,
|
||||
"frontmatter": parsed_frontmatter,
|
||||
}
|
||||
|
||||
def _empty_json_payload() -> dict:
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"content": None,
|
||||
"frontmatter": None,
|
||||
}
|
||||
|
||||
def _search_results(payload: object) -> list:
|
||||
if isinstance(payload, dict):
|
||||
results = payload.get("results")
|
||||
return results if isinstance(results, list) else []
|
||||
if hasattr(payload, "results"):
|
||||
results = getattr(payload, "results")
|
||||
return results if isinstance(results, list) else []
|
||||
return []
|
||||
|
||||
def _result_title(item: object) -> str:
|
||||
if isinstance(item, dict):
|
||||
return str(item.get("title") or "")
|
||||
return str(getattr(item, "title", "") or "")
|
||||
|
||||
def _result_permalink(item: object) -> Optional[str]:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("permalink")
|
||||
return str(value) if value else None
|
||||
value = getattr(item, "permalink", None)
|
||||
return str(value) if value else None
|
||||
|
||||
def _result_file_path(item: object) -> Optional[str]:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("file_path")
|
||||
return str(value) if value else None
|
||||
value = getattr(item, "file_path", None)
|
||||
return str(value) if value else None
|
||||
|
||||
try:
|
||||
# Try to resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path)
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
@@ -121,12 +230,8 @@ async def read_note(
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
@@ -135,32 +240,49 @@ async def read_note(
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await search_notes.fn(
|
||||
query=identifier, search_type="title", project=project, context=context
|
||||
query=identifier,
|
||||
search_type="title",
|
||||
project=active_project.name,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Handle both SearchResponse object and error strings
|
||||
if title_results and hasattr(title_results, "results") and title_results.results:
|
||||
result = title_results.results[0] # Get the first/best match
|
||||
if result.permalink:
|
||||
title_candidates = _search_results(title_results)
|
||||
if title_candidates:
|
||||
# Trigger: direct resolution failed and title search returned candidates.
|
||||
# Why: avoid returning unrelated notes when search yields only fuzzy matches.
|
||||
# Outcome: fetch content only when a true exact title match exists.
|
||||
result = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in title_candidates
|
||||
if _is_exact_title_match(identifier, _result_title(candidate))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"No exact title match found for: {identifier}")
|
||||
elif _result_permalink(result):
|
||||
try:
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
_result_permalink(result) or "", strict=True
|
||||
)
|
||||
|
||||
# Fetch content using the entity ID
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Found note by title search: {result.permalink}")
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
logger.info(
|
||||
f"Found note by exact title search: {_result_permalink(result)}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
f"Failed to fetch content for found title match {result.permalink}: {e}"
|
||||
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -170,17 +292,32 @@ async def read_note(
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await search_notes.fn(
|
||||
query=identifier, search_type="text", project=project, context=context
|
||||
query=identifier,
|
||||
search_type="text",
|
||||
project=active_project.name,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
# Handle both SearchResponse object and error strings
|
||||
if not text_results or not hasattr(text_results, "results") or not text_results.results:
|
||||
# No results at all
|
||||
text_candidates = _search_results(text_results)
|
||||
if not text_candidates:
|
||||
if output_format == "json":
|
||||
return _empty_json_payload()
|
||||
return format_not_found_message(active_project.name, identifier)
|
||||
else:
|
||||
# We found some related results
|
||||
return format_related_results(active_project.name, identifier, text_results.results[:5])
|
||||
if output_format == "json":
|
||||
payload = _empty_json_payload()
|
||||
payload["related_results"] = [
|
||||
{
|
||||
"title": _result_title(result),
|
||||
"permalink": _result_permalink(result),
|
||||
"file_path": _result_file_path(result),
|
||||
}
|
||||
for result in text_candidates[:5]
|
||||
]
|
||||
return payload
|
||||
return format_related_results(active_project.name, identifier, text_candidates[:5])
|
||||
|
||||
|
||||
def format_not_found_message(project: str | None, identifier: str) -> str:
|
||||
@@ -240,14 +377,31 @@ def format_related_results(project: str | None, identifier: str, results) -> str
|
||||
""")
|
||||
|
||||
for i, result in enumerate(results):
|
||||
title = result.get("title") if isinstance(result, dict) else getattr(result, "title", None)
|
||||
permalink = (
|
||||
result.get("permalink")
|
||||
if isinstance(result, dict)
|
||||
else getattr(result, "permalink", None)
|
||||
)
|
||||
result_type = (
|
||||
result.get("type") if isinstance(result, dict) else getattr(result, "type", None)
|
||||
)
|
||||
normalized_type = (
|
||||
result_type
|
||||
if isinstance(result_type, str)
|
||||
else str(getattr(result_type, "value", result_type))
|
||||
if result_type is not None
|
||||
else None
|
||||
)
|
||||
|
||||
message += dedent(f"""
|
||||
## {i + 1}. {result.title}
|
||||
- **Type**: {result.type.value}
|
||||
- **Permalink**: {result.permalink}
|
||||
## {i + 1}. {title or "Untitled"}
|
||||
- **Type**: {normalized_type or "entity"}
|
||||
- **Permalink**: {permalink or "unknown"}
|
||||
|
||||
You can read this note with:
|
||||
```
|
||||
read_note(project="{project}", {result.permalink}")
|
||||
read_note(project="{project}", identifier="{permalink or ""}")
|
||||
```
|
||||
|
||||
""")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Recent activity tool for Basic Memory MCP server."""
|
||||
|
||||
from datetime import timezone
|
||||
from typing import List, Union, Optional
|
||||
from typing import List, Union, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
@@ -40,8 +40,10 @@ async def recent_activity(
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
) -> str | list[dict]:
|
||||
"""Get recent activity for a specific project or across all projects.
|
||||
|
||||
Project Resolution:
|
||||
@@ -77,6 +79,8 @@ async def recent_activity(
|
||||
project: Project name to query. Optional - server will resolve using the
|
||||
hierarchy above. If unknown, use list_memory_projects() to discover
|
||||
available projects.
|
||||
output_format: "text" returns human-readable summary text. "json" returns
|
||||
a flat list of recent entity items.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -185,6 +189,12 @@ async def recent_activity(
|
||||
most_active_count = item_count
|
||||
most_active_project = project_info.name
|
||||
|
||||
if output_format == "json":
|
||||
rows: list[dict] = []
|
||||
for project_name, project_activity in projects_activity.items():
|
||||
rows.extend(_extract_recent_entity_rows(project_activity.activity, project_name))
|
||||
return rows
|
||||
|
||||
# Build summary stats
|
||||
summary = ActivityStats(
|
||||
total_projects=len(project_list.projects),
|
||||
@@ -246,7 +256,10 @@ async def recent_activity(
|
||||
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
|
||||
async with get_project_client(resolved_project, context) as (client, active_project):
|
||||
async with get_project_client(resolved_project, workspace, context) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/memory/recent",
|
||||
@@ -254,6 +267,9 @@ async def recent_activity(
|
||||
)
|
||||
activity_data = GraphContext.model_validate(response.json())
|
||||
|
||||
if output_format == "json":
|
||||
return _extract_recent_entity_rows(activity_data)
|
||||
|
||||
# Format project-specific mode output
|
||||
return _format_project_output(resolved_project, activity_data, timeframe, type)
|
||||
|
||||
@@ -311,6 +327,29 @@ async def _get_project_activity(
|
||||
)
|
||||
|
||||
|
||||
def _extract_recent_entity_rows(
|
||||
activity_data: GraphContext, project_name: Optional[str] = None
|
||||
) -> list[dict]:
|
||||
"""Flatten GraphContext into a list of recent entity rows."""
|
||||
rows: list[dict] = []
|
||||
for result in activity_data.results:
|
||||
primary = result.primary_result
|
||||
if primary.type != "entity":
|
||||
continue
|
||||
row = {
|
||||
"title": primary.title,
|
||||
"permalink": primary.permalink,
|
||||
"file_path": primary.file_path,
|
||||
"created_at": (
|
||||
primary.created_at.isoformat() if getattr(primary, "created_at", None) else None
|
||||
),
|
||||
}
|
||||
if project_name is not None:
|
||||
row["project"] = project_name
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _format_discovery_output(
|
||||
projects_activity: dict, summary: ActivityStats, timeframe: str, guidance: str
|
||||
) -> str:
|
||||
|
||||
@@ -9,19 +9,74 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.schema import ValidationReport, InferenceReport, DriftReport
|
||||
|
||||
|
||||
def _no_notes_guidance(note_type: str, tool_name: str) -> str:
|
||||
"""Build guidance string when no notes of a given type exist.
|
||||
|
||||
Used by schema_validate when the project has zero notes of the
|
||||
requested type — a different situation from "notes exist but no schema".
|
||||
"""
|
||||
return (
|
||||
f"# No Notes Found of Type '{note_type}'\n\n"
|
||||
f"`{tool_name}` found no notes with type '{note_type}' in the project.\n\n"
|
||||
f"## Next Steps\n\n"
|
||||
f"1. **Create notes of this type** — use `write_note` with "
|
||||
f'`note_type="{note_type}"` to create notes\n'
|
||||
f"2. **Check existing types** — use `search_notes` with `entity_types` "
|
||||
f"filter to see what types exist\n"
|
||||
f"3. **Browse content** — use `list_directory` or `recent_activity` to "
|
||||
f"see what's in the project\n"
|
||||
)
|
||||
|
||||
|
||||
def _no_schema_guidance(note_type: str, tool_name: str) -> str:
|
||||
"""Build guidance string when no schema exists for a note type.
|
||||
|
||||
Used by schema_validate and schema_diff to explain what happened
|
||||
and how to create a schema.
|
||||
"""
|
||||
return (
|
||||
f"# No Schema Found for '{note_type}'\n\n"
|
||||
f"`{tool_name}` requires a schema note to exist for type '{note_type}'.\n\n"
|
||||
f"## How to Create a Schema\n\n"
|
||||
f'1. **Infer from existing notes** — run `schema_infer("{note_type}")` to '
|
||||
f"analyze your notes and get a suggested schema\n"
|
||||
f"2. **Create a schema note** — write a markdown file with this frontmatter:\n\n"
|
||||
f"```yaml\n"
|
||||
f"---\n"
|
||||
f"title: {note_type.title()}\n"
|
||||
f"type: schema\n"
|
||||
f"entity: {note_type}\n"
|
||||
f"version: 1\n"
|
||||
f"schema:\n"
|
||||
f" name: string, full name\n"
|
||||
f" role?: string, job title\n"
|
||||
f"settings:\n"
|
||||
f" validation: warn\n"
|
||||
f"---\n"
|
||||
f"```\n\n"
|
||||
f"Schema fields use Picoschema notation:\n"
|
||||
f"- `field_name: type, description` — required field\n"
|
||||
f"- `field_name?: type, description` — optional field\n"
|
||||
f"- Supported types: `string`, `number`, `boolean`, `string[]`\n\n"
|
||||
f"3. **Sync** — run `basic-memory sync` or wait for auto-sync to pick up "
|
||||
f"the new schema note\n"
|
||||
f'4. **Re-run** — call `{tool_name}("{note_type}")` again\n'
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Validate notes against their Picoschema definitions.",
|
||||
)
|
||||
async def schema_validate(
|
||||
entity_type: Optional[str] = None,
|
||||
note_type: Optional[str] = None,
|
||||
identifier: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> ValidationReport | str:
|
||||
"""Validate notes against their resolved schema.
|
||||
@@ -32,7 +87,7 @@ async def schema_validate(
|
||||
Schemas are resolved in priority order:
|
||||
1. Inline schema (dict in frontmatter)
|
||||
2. Explicit reference (string in frontmatter)
|
||||
3. Implicit by type (type field matches schema note entity field)
|
||||
3. Implicit by type (type field matches schema note's entity field)
|
||||
4. No schema (no validation)
|
||||
|
||||
Project Resolution:
|
||||
@@ -40,7 +95,7 @@ async def schema_validate(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: Entity type to batch-validate (e.g., "Person").
|
||||
note_type: Note type to batch-validate (e.g., "person", "meeting").
|
||||
If provided, validates all notes of this type.
|
||||
identifier: Specific note to validate (permalink, title, or path).
|
||||
If provided, validates only this note.
|
||||
@@ -51,20 +106,19 @@ async def schema_validate(
|
||||
ValidationReport with per-note results, or error guidance string
|
||||
|
||||
Examples:
|
||||
# Validate all Person notes
|
||||
schema_validate(entity_type="Person")
|
||||
# Validate all person notes
|
||||
schema_validate(note_type="person")
|
||||
|
||||
# Validate a specific note
|
||||
schema_validate(identifier="people/paul-graham")
|
||||
|
||||
# Validate in a specific project
|
||||
schema_validate(entity_type="Person", project="my-research")
|
||||
schema_validate(note_type="person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_validate project={active_project.name} "
|
||||
f"entity_type={entity_type} identifier={identifier}"
|
||||
f"note_type={note_type} identifier={identifier}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -72,7 +126,7 @@ async def schema_validate(
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.validate(
|
||||
entity_type=entity_type,
|
||||
entity_type=note_type,
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
@@ -81,6 +135,21 @@ async def schema_validate(
|
||||
f"total={result.total_notes} valid={result.valid_count} "
|
||||
f"warnings={result.warning_count} errors={result.error_count}"
|
||||
)
|
||||
|
||||
# --- No notes guard ---
|
||||
# Trigger: no entities of this type exist in the project
|
||||
# Why: can't validate notes that don't exist yet
|
||||
# Outcome: return guidance on creating notes of this type
|
||||
if note_type and result.total_entities == 0:
|
||||
return _no_notes_guidance(note_type, "schema_validate")
|
||||
|
||||
# --- No schema guard ---
|
||||
# Trigger: entities exist but none were validated (no schema found)
|
||||
# Why: notes of this type exist but no schema was found, so none were validated
|
||||
# Outcome: return guidance on how to create a schema
|
||||
if note_type and result.total_notes == 0:
|
||||
return _no_schema_guidance(note_type, "schema_validate")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -89,7 +158,7 @@ async def schema_validate(
|
||||
f"# Schema Validation Failed\n\n"
|
||||
f"Error validating schemas: {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure schema notes exist (type: schema) for the target entity type\n"
|
||||
f"1. Ensure schema notes exist (type: schema) for the target note type\n"
|
||||
f"2. Check that notes have the correct type in frontmatter\n"
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
@@ -99,9 +168,10 @@ async def schema_validate(
|
||||
description="Analyze existing notes and suggest a Picoschema definition.",
|
||||
)
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> InferenceReport | str:
|
||||
"""Analyze existing notes and suggest a schema definition.
|
||||
@@ -120,7 +190,7 @@ async def schema_infer(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to analyze (e.g., "Person", "meeting").
|
||||
note_type: The note type to analyze (e.g., "person", "meeting").
|
||||
threshold: Minimum frequency (0-1) for a field to be suggested as optional.
|
||||
Default 0.25 (25%). Fields above 95% become required.
|
||||
project: Project name. Optional -- server will resolve.
|
||||
@@ -130,44 +200,67 @@ async def schema_infer(
|
||||
InferenceReport with frequency data and suggested schema, or error string
|
||||
|
||||
Examples:
|
||||
# Infer schema for Person notes
|
||||
schema_infer("Person")
|
||||
# Infer schema for person notes
|
||||
schema_infer("person")
|
||||
|
||||
# Use a higher threshold (50% minimum)
|
||||
schema_infer("meeting", threshold=0.5)
|
||||
|
||||
# Infer in a specific project
|
||||
schema_infer("Person", project="my-research")
|
||||
schema_infer("person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_infer project={active_project.name} "
|
||||
f"entity_type={entity_type} threshold={threshold}"
|
||||
f"note_type={note_type} threshold={threshold}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.infer(entity_type, threshold=threshold)
|
||||
result = await schema_client.infer(note_type, threshold=threshold)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_infer project={active_project.name} "
|
||||
f"entity_type={entity_type} notes_analyzed={result.notes_analyzed} "
|
||||
f"note_type={note_type} notes_analyzed={result.notes_analyzed} "
|
||||
f"required={len(result.suggested_required)} "
|
||||
f"optional={len(result.suggested_optional)}"
|
||||
)
|
||||
|
||||
# --- Empty schema guard ---
|
||||
# Trigger: notes were analyzed but no fields met the threshold
|
||||
# Why: returning hundreds of excluded fields overwhelms the LLM context
|
||||
# Outcome: return actionable guidance instead of a massive empty result
|
||||
if result.notes_analyzed > 0 and not result.suggested_schema:
|
||||
return (
|
||||
f"# No Schema Pattern Found\n\n"
|
||||
f"Analyzed {result.notes_analyzed} notes of type '{note_type}', "
|
||||
f"but no observation or relation appeared in enough notes to suggest "
|
||||
f"a schema (threshold: {threshold:.0%}).\n\n"
|
||||
f"This usually means '{note_type}' is too broad — the notes don't "
|
||||
f"share a consistent structure.\n\n"
|
||||
f"## Suggestions\n"
|
||||
f"1. **Use a more specific type** — try `search_notes` with "
|
||||
f"`entity_types` filter to see what types exist\n"
|
||||
f"2. **Lower the threshold** — "
|
||||
f'`schema_infer("{note_type}", threshold=0.1)` to include '
|
||||
f"rarer fields\n"
|
||||
f"3. **Create typed notes** — use `write_note` with a specific "
|
||||
f'`note_type` (e.g., "person", "meeting") to build consistent '
|
||||
f"structure\n"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schema inference failed: {e}, project: {active_project.name}")
|
||||
return (
|
||||
f"# Schema Inference Failed\n\n"
|
||||
f"Error inferring schema for '{entity_type}': {e}\n\n"
|
||||
f"Error inferring schema for type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure notes of type '{entity_type}' exist in the project\n"
|
||||
f'2. Try searching: `search_notes("{entity_type}", types=["{entity_type}"])`\n'
|
||||
f"1. Ensure notes of type '{note_type}' exist in the project\n"
|
||||
f'2. Try searching: `search_notes("{note_type}", types=["{note_type}"])`\n'
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
|
||||
@@ -176,13 +269,14 @@ async def schema_infer(
|
||||
description="Detect drift between a schema definition and actual note usage.",
|
||||
)
|
||||
async def schema_diff(
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> DriftReport | str:
|
||||
"""Detect drift between a schema definition and actual note usage.
|
||||
|
||||
Compares the existing schema for an entity type against how notes of
|
||||
Compares the existing schema for a note type against how notes of
|
||||
that type are actually structured. Identifies new fields that have
|
||||
appeared, declared fields that are rarely used, and cardinality changes
|
||||
(single-value vs array).
|
||||
@@ -195,7 +289,7 @@ async def schema_diff(
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to check for drift (e.g., "Person").
|
||||
note_type: The note type to check for drift (e.g., "person").
|
||||
project: Project name. Optional -- server will resolve.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
@@ -204,41 +298,47 @@ async def schema_diff(
|
||||
or error guidance string
|
||||
|
||||
Examples:
|
||||
# Check drift for Person schema
|
||||
schema_diff("Person")
|
||||
# Check drift for person schema
|
||||
schema_diff("person")
|
||||
|
||||
# Check drift in a specific project
|
||||
schema_diff("Person", project="my-research")
|
||||
schema_diff("person", project="my-research")
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_diff project={active_project.name} "
|
||||
f"entity_type={entity_type}"
|
||||
f"MCP tool call tool=schema_diff project={active_project.name} note_type={note_type}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.diff(entity_type)
|
||||
result = await schema_client.diff(note_type)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_diff project={active_project.name} "
|
||||
f"entity_type={entity_type} "
|
||||
f"note_type={note_type} schema_found={result.schema_found} "
|
||||
f"new_fields={len(result.new_fields)} "
|
||||
f"dropped_fields={len(result.dropped_fields)} "
|
||||
f"cardinality_changes={len(result.cardinality_changes)}"
|
||||
)
|
||||
|
||||
# --- No schema guard ---
|
||||
# Trigger: API reports no schema was found for this type
|
||||
# Why: diff requires a schema to compare against
|
||||
# Outcome: return guidance on how to create a schema
|
||||
if not result.schema_found:
|
||||
return _no_schema_guidance(note_type, "schema_diff")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schema diff failed: {e}, project: {active_project.name}")
|
||||
return (
|
||||
f"# Schema Diff Failed\n\n"
|
||||
f"Error detecting drift for '{entity_type}': {e}\n\n"
|
||||
f"Error detecting drift for type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure a schema note exists for entity type '{entity_type}'\n"
|
||||
f"2. Ensure notes of type '{entity_type}' exist in the project\n"
|
||||
f"1. Ensure a schema note exists for type '{note_type}'\n"
|
||||
f"2. Ensure notes of type '{note_type}' exist in the project\n"
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,9 @@ from typing import List, Optional, Dict, Any, Literal
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.formatting import format_search_results_ascii
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.container import get_container
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.search import (
|
||||
SearchItemType,
|
||||
@@ -17,6 +18,17 @@ from basic_memory.schemas.search import (
|
||||
)
|
||||
|
||||
|
||||
def _semantic_search_enabled_for_text_search() -> bool:
|
||||
"""Resolve semantic-search enablement in both MCP and CLI invocation paths."""
|
||||
try:
|
||||
return get_container().config.semantic_search_enabled
|
||||
except RuntimeError:
|
||||
# Trigger: MCP container is not initialized (e.g., `bm tool search-notes` direct call).
|
||||
# Why: CLI path still needs the same semantic-default behavior as MCP server path.
|
||||
# Outcome: load config directly and keep text-mode retrieval behavior consistent.
|
||||
return ConfigManager().config.semantic_search_enabled
|
||||
|
||||
|
||||
def _format_search_error_response(
|
||||
project: str, error_message: str, query: str, search_type: str = "text"
|
||||
) -> str:
|
||||
@@ -231,23 +243,26 @@ Error searching for '{query}': {error_message}
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
# TODO: re-enable once MCP client rendering is working
|
||||
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
)
|
||||
async def search_notes(
|
||||
query: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
min_similarity: Optional[float] = None,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
) -> SearchResponse | dict | str:
|
||||
"""Search across all content in the knowledge base with comprehensive syntax support.
|
||||
|
||||
This tool searches the knowledge base using full-text search, pattern matching,
|
||||
@@ -265,7 +280,8 @@ async def search_notes(
|
||||
- `search_notes("work-docs", "'exact phrase'")` - Search for exact phrase match
|
||||
|
||||
### Advanced Boolean Searches
|
||||
- `search_notes("my-project", "term1 term2")` - Find content with both terms (implicit AND)
|
||||
- `search_notes("my-project", "term1 term2")` - Strict implicit-AND first; retries with
|
||||
relaxed OR terms only if strict search returns no results
|
||||
- `search_notes("my-project", "term1 AND term2")` - Explicit AND search (both terms required)
|
||||
- `search_notes("my-project", "term1 OR term2")` - Either term can be present
|
||||
- `search_notes("my-project", "term1 NOT term2")` - Include term1 but exclude term2
|
||||
@@ -279,7 +295,8 @@ async def search_notes(
|
||||
### Search Type Examples
|
||||
- `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles
|
||||
- `search_notes("work-docs", "docs/meeting-*", search_type="permalink")` - Pattern match permalinks
|
||||
- `search_notes("research", "keyword", search_type="text")` - Full-text search (default)
|
||||
- `search_notes("research", "keyword", search_type="text")` - Text search (default; auto-upgrades
|
||||
to hybrid when semantic search is enabled)
|
||||
|
||||
### Filtering Options
|
||||
- `search_notes("my-project", "query", types=["entity"])` - Search only entities
|
||||
@@ -322,15 +339,19 @@ async def search_notes(
|
||||
page: The page number of results to return (default 1)
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of:
|
||||
"text", "title", "permalink", "vector", "hybrid" (default: "text")
|
||||
output_format: "default" returns structured data, "ascii" returns a plain text table,
|
||||
"ansi" returns a colorized table for TUI clients.
|
||||
"text", "title", "permalink", "vector", "semantic", "hybrid" (default: "text";
|
||||
text mode auto-upgrades to hybrid when semantic search is enabled)
|
||||
output_format: "text" preserves existing structured search response behavior.
|
||||
"json" returns a machine-readable dictionary payload.
|
||||
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"])
|
||||
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"]
|
||||
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
|
||||
min_similarity: Optional float to override the global semantic_min_similarity threshold
|
||||
for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision.
|
||||
Only applies to vector and hybrid search types.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -339,6 +360,7 @@ async def search_notes(
|
||||
Examples:
|
||||
# Basic text search
|
||||
results = await search_notes("project planning")
|
||||
# Plain multi-term text uses strict matching first, then relaxed OR fallback if needed
|
||||
|
||||
# Boolean AND search (both terms must be present)
|
||||
results = await search_notes("project AND planning")
|
||||
@@ -399,45 +421,62 @@ async def search_notes(
|
||||
types = types or []
|
||||
entity_types = entity_types or []
|
||||
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Set the appropriate search field based on search_type
|
||||
if search_type == "text":
|
||||
search_query.text = query
|
||||
elif search_type == "vector":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif search_type == "hybrid":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif search_type == "title":
|
||||
search_query.title = query
|
||||
elif search_type == "permalink" and "*" in query:
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else: # pragma: no cover
|
||||
search_query.text = query # Default to text search
|
||||
|
||||
# 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 types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Handle memory:// URLs by resolving to permalink search
|
||||
_, resolved_query, is_memory_url = await resolve_project_and_path(
|
||||
client, query, project, context
|
||||
)
|
||||
if is_memory_url:
|
||||
query = resolved_query
|
||||
search_type = "permalink"
|
||||
|
||||
try:
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Map search_type to the appropriate query field and retrieval mode
|
||||
valid_search_types = {"text", "title", "permalink", "vector", "semantic", "hybrid"}
|
||||
if search_type == "text":
|
||||
search_query.text = query
|
||||
# Upgrade to hybrid when semantic search is available —
|
||||
# combines FTS keyword matching with vector similarity for better results
|
||||
if _semantic_search_enabled_for_text_search():
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif search_type in ("vector", "semantic"):
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif search_type == "hybrid":
|
||||
search_query.text = query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif search_type == "title":
|
||||
search_query.title = query
|
||||
elif search_type == "permalink" and "*" in query:
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid search_type '{search_type}'. "
|
||||
f"Valid options: {', '.join(sorted(valid_search_types))}"
|
||||
)
|
||||
|
||||
# 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 types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
if min_similarity is not None:
|
||||
search_query.min_similarity = min_similarity
|
||||
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
# Import here to avoid circular import (tools → clients → utils → tools)
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
@@ -457,12 +496,8 @@ async def search_notes(
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_search_results_ascii(
|
||||
result,
|
||||
query=query,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
if output_format == "json":
|
||||
return result.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
return result
|
||||
|
||||
@@ -478,6 +513,7 @@ async def search_notes(
|
||||
async def search_by_metadata(
|
||||
filters: Dict[str, Any],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
context: Context | None = None,
|
||||
@@ -507,7 +543,7 @@ async def search_by_metadata(
|
||||
page = (offset // limit) + 1
|
||||
offset_within_page = offset % limit
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info(
|
||||
f"Structured search in project {active_project.name} filters={filters} limit={limit} offset={offset}"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ async def search_notes_ui(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
output_format="default",
|
||||
output_format="json",
|
||||
types=types,
|
||||
entity_types=entity_types,
|
||||
after_date=after_date,
|
||||
@@ -62,7 +62,7 @@ async def search_notes_ui(
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": result.model_dump(),
|
||||
"toolOutput": result,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -96,7 +96,7 @@ async def read_note_ui(
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="default",
|
||||
output_format="text",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from basic_memory.mcp.tools.read_note import read_note
|
||||
async def view_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
context: Context | None = None,
|
||||
@@ -57,7 +58,14 @@ async def view_note(
|
||||
logger.info(f"Viewing note: {identifier} in project: {project}")
|
||||
|
||||
# Call the existing read_note logic
|
||||
content = await read_note.fn(identifier, project, page, page_size, context)
|
||||
content = await read_note.fn(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Check if this is an error message (note not found)
|
||||
if "# Note Not Found" in content:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Workspace discovery MCP tool."""
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@mcp.tool(description="List available cloud workspaces (tenant_id, type, role, and name).")
|
||||
async def list_workspaces(context: Context | None = None) -> str:
|
||||
"""List workspaces available to the current cloud user."""
|
||||
workspaces = await get_available_workspaces(context=context)
|
||||
|
||||
if not workspaces:
|
||||
return (
|
||||
"# No Workspaces Available\n\n"
|
||||
"No accessible workspaces were found for this account. "
|
||||
"Ensure the account has an active subscription and tenant access."
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"# Available Workspaces ({len(workspaces)})",
|
||||
"",
|
||||
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
|
||||
"",
|
||||
]
|
||||
for workspace in workspaces:
|
||||
lines.append(
|
||||
f"- {workspace.name} "
|
||||
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Write note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import List, Union, Optional
|
||||
from typing import List, Union, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -22,10 +22,13 @@ async def write_note(
|
||||
content: str,
|
||||
directory: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
tags: list[str] | str | None = None,
|
||||
note_type: str = "note",
|
||||
metadata: dict | None = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
) -> str | dict:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
Creates or updates a markdown note with semantic observations and relations.
|
||||
@@ -67,6 +70,11 @@ async def write_note(
|
||||
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
||||
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
|
||||
Can be "guide", "report", "config", "person", etc.
|
||||
metadata: Optional dict of extra frontmatter fields merged into entity_metadata.
|
||||
Useful for schema notes or any note that needs custom YAML frontmatter
|
||||
beyond title/type/tags. Nested dicts are supported.
|
||||
output_format: "text" returns the existing markdown summary. "json" returns
|
||||
machine-readable metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -105,11 +113,25 @@ async def write_note(
|
||||
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
|
||||
)
|
||||
|
||||
# Create a schema note with custom frontmatter via metadata
|
||||
write_note(
|
||||
title="Person",
|
||||
directory="schemas",
|
||||
note_type="schema",
|
||||
content="# Person\\n\\nSchema for person entities.",
|
||||
metadata={
|
||||
"entity": "person",
|
||||
"version": 1,
|
||||
"schema": {"name": "string", "role?": "string"},
|
||||
"settings": {"validation": "warn"},
|
||||
},
|
||||
)
|
||||
|
||||
Raises:
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If directory path attempts path traversal
|
||||
"""
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
|
||||
)
|
||||
@@ -126,19 +148,35 @@ async def write_note(
|
||||
directory=directory,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"action": "created",
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
# Create the entity request
|
||||
metadata = {"tags": tag_list} if tag_list else None
|
||||
|
||||
# Build entity_metadata from optional metadata, then explicit tags on top
|
||||
# Order matters: explicit tags parameter takes precedence over metadata["tags"]
|
||||
entity_metadata = {}
|
||||
if metadata:
|
||||
entity_metadata.update(metadata)
|
||||
if tag_list:
|
||||
entity_metadata["tags"] = tag_list
|
||||
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
entity_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
entity_metadata=entity_metadata or None,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
@@ -220,5 +258,14 @@ async def write_note(
|
||||
logger.info(
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"action": action.lower(),
|
||||
}
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
"""Unified project resolution across MCP, API, and CLI.
|
||||
|
||||
This module provides a single canonical implementation of project resolution
|
||||
logic, eliminating duplicated decision trees across the codebase.
|
||||
|
||||
The resolution follows a unified linear priority chain that works
|
||||
identically in both local and cloud modes:
|
||||
|
||||
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT: Project passed directly to operation
|
||||
3. DEFAULT: Default project when default_project_mode=true
|
||||
4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE
|
||||
"""
|
||||
"""Unified project resolution across MCP, API, and CLI."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@@ -23,23 +11,16 @@ from loguru import logger
|
||||
class ResolutionMode(Enum):
|
||||
"""How the project was resolved."""
|
||||
|
||||
CLOUD_EXPLICIT = auto() # Explicit project in cloud mode
|
||||
CLOUD_DISCOVERY = auto() # Discovery mode allowed in cloud (no project)
|
||||
ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var
|
||||
EXPLICIT = auto() # Explicit project parameter
|
||||
DEFAULT = auto() # default_project with default_project_mode=true
|
||||
DEFAULT = auto() # default_project from config
|
||||
DISCOVERY = auto() # Discovery mode allowed (no project)
|
||||
NONE = auto() # No resolution possible
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedProject:
|
||||
"""Result of project resolution.
|
||||
|
||||
Attributes:
|
||||
project: The resolved project name, or None if not resolved
|
||||
mode: How the project was resolved
|
||||
reason: Human-readable explanation of resolution
|
||||
"""
|
||||
"""Result of project resolution."""
|
||||
|
||||
project: Optional[str]
|
||||
mode: ResolutionMode
|
||||
@@ -47,63 +28,29 @@ class ResolvedProject:
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
"""Whether a project was successfully resolved."""
|
||||
return self.project is not None
|
||||
|
||||
@property
|
||||
def is_discovery_mode(self) -> bool:
|
||||
"""Whether we're in discovery mode (no specific project)."""
|
||||
return self.mode == ResolutionMode.CLOUD_DISCOVERY or (
|
||||
self.mode == ResolutionMode.NONE and self.project is None
|
||||
)
|
||||
return self.mode in {ResolutionMode.DISCOVERY, ResolutionMode.NONE} and self.project is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectResolver:
|
||||
"""Unified project resolution logic.
|
||||
"""Unified project resolution logic."""
|
||||
|
||||
Resolves the effective project given requested project, environment
|
||||
constraints, and configuration settings.
|
||||
|
||||
This is the single canonical implementation of project resolution,
|
||||
used by MCP tools, API routes, and CLI commands.
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
constrained_project: Optional env-constrained project override
|
||||
(typically from BASIC_MEMORY_MCP_PROJECT)
|
||||
"""
|
||||
|
||||
cloud_mode: bool = False
|
||||
default_project_mode: bool = False
|
||||
default_project: Optional[str] = None
|
||||
constrained_project: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
cloud_mode: bool = False,
|
||||
default_project_mode: bool = False,
|
||||
default_project: Optional[str] = None,
|
||||
) -> "ProjectResolver":
|
||||
"""Create resolver with constrained_project from environment.
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
|
||||
Returns:
|
||||
ProjectResolver configured with current environment
|
||||
"""
|
||||
constrained = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
"""Create resolver with constrained_project from environment."""
|
||||
return cls(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
constrained_project=constrained,
|
||||
constrained_project=os.environ.get("BASIC_MEMORY_MCP_PROJECT"),
|
||||
)
|
||||
|
||||
def resolve(
|
||||
@@ -111,66 +58,39 @@ class ProjectResolver:
|
||||
project: Optional[str] = None,
|
||||
allow_discovery: bool = False,
|
||||
) -> ResolvedProject:
|
||||
"""Resolve project using a unified linear priority chain.
|
||||
|
||||
The same resolution order applies in both local and cloud modes:
|
||||
1. ENV_CONSTRAINT — BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT — project parameter passed directly
|
||||
3. DEFAULT — default project when default_project_mode=true
|
||||
4. Fallback — cloud: CLOUD_DISCOVERY or ValueError; local: NONE
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None in cloud mode
|
||||
for discovery operations (e.g., recent_activity across projects)
|
||||
|
||||
Returns:
|
||||
ResolvedProject with project name, resolution mode, and reason
|
||||
|
||||
Raises:
|
||||
ValueError: If in cloud mode and no project could be resolved
|
||||
(unless allow_discovery=True)
|
||||
"""
|
||||
# --- Priority 1: ENV constraint overrides everything ---
|
||||
"""Resolve project using a unified linear priority chain."""
|
||||
if self.constrained_project:
|
||||
logger.debug(f"Using CLI constrained project: {self.constrained_project}")
|
||||
logger.debug(f"Using constrained project from env: {self.constrained_project}")
|
||||
return ResolvedProject(
|
||||
project=self.constrained_project,
|
||||
mode=ResolutionMode.ENV_CONSTRAINT,
|
||||
reason=f"Environment constraint: BASIC_MEMORY_MCP_PROJECT={self.constrained_project}",
|
||||
)
|
||||
|
||||
# --- Priority 2: Explicit project parameter ---
|
||||
if project:
|
||||
mode = ResolutionMode.CLOUD_EXPLICIT if self.cloud_mode else ResolutionMode.EXPLICIT
|
||||
logger.debug(f"Using explicit project parameter: {project}")
|
||||
return ResolvedProject(
|
||||
project=project,
|
||||
mode=mode,
|
||||
mode=ResolutionMode.EXPLICIT,
|
||||
reason=f"Explicit parameter: {project}",
|
||||
)
|
||||
|
||||
# --- Priority 3: Default project mode ---
|
||||
if self.default_project_mode and self.default_project:
|
||||
if self.default_project:
|
||||
logger.debug(f"Using default project from config: {self.default_project}")
|
||||
return ResolvedProject(
|
||||
project=self.default_project,
|
||||
mode=ResolutionMode.DEFAULT,
|
||||
reason=f"Default project mode: {self.default_project}",
|
||||
reason=f"Default project: {self.default_project}",
|
||||
)
|
||||
|
||||
# --- Fallback: mode-dependent behavior ---
|
||||
if self.cloud_mode:
|
||||
if allow_discovery:
|
||||
logger.debug("Cloud mode: discovery mode allowed, no project required")
|
||||
return ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.CLOUD_DISCOVERY,
|
||||
reason="Discovery mode enabled in cloud",
|
||||
)
|
||||
raise ValueError("No project specified. Project is required for cloud mode.")
|
||||
if allow_discovery:
|
||||
logger.debug("No project resolved, using discovery mode")
|
||||
return ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.DISCOVERY,
|
||||
reason="Discovery mode enabled",
|
||||
)
|
||||
|
||||
# Local mode: no resolution possible
|
||||
logger.debug("No project resolution possible")
|
||||
return ResolvedProject(
|
||||
project=None,
|
||||
@@ -183,24 +103,11 @@ class ProjectResolver:
|
||||
project: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> ResolvedProject:
|
||||
"""Resolve project, raising an error if not resolved.
|
||||
|
||||
Convenience method for operations that require a project.
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
error_message: Custom error message if project not resolved
|
||||
|
||||
Returns:
|
||||
ResolvedProject (always with a non-None project)
|
||||
|
||||
Raises:
|
||||
ValueError: If project could not be resolved
|
||||
"""
|
||||
"""Resolve project, raising an error if not resolved."""
|
||||
result = self.resolve(project, allow_discovery=False)
|
||||
if not result.is_resolved:
|
||||
msg = error_message or (
|
||||
"No project specified. Either set 'default_project_mode=true' in config, "
|
||||
"No project specified. Either set 'default_project' in config, "
|
||||
"or provide a 'project' argument."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
@@ -51,6 +51,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
self._app_config = app_config or ConfigManager().config
|
||||
self._semantic_enabled = self._app_config.semantic_search_enabled
|
||||
self._semantic_vector_k = self._app_config.semantic_vector_k
|
||||
self._semantic_min_similarity = self._app_config.semantic_min_similarity
|
||||
self._embedding_provider = embedding_provider
|
||||
self._vector_dimensions = 384
|
||||
self._vector_tables_initialized = False
|
||||
@@ -64,17 +65,16 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
async def init_search_index(self):
|
||||
"""Create Postgres table with tsvector column and GIN indexes.
|
||||
|
||||
Note: This is handled by Alembic migrations. This method is a no-op
|
||||
for Postgres as the schema is created via migrations.
|
||||
Note: FTS schema is handled by Alembic migrations. Vector tables are
|
||||
created here at startup so missing pgvector or provider errors surface
|
||||
immediately.
|
||||
"""
|
||||
logger.info("PostgreSQL search index initialization handled by migrations")
|
||||
# Table creation is done via Alembic migrations
|
||||
# This includes:
|
||||
# - CREATE TABLE search_index (...)
|
||||
# - ADD COLUMN textsearchable_index_col tsvector GENERATED ALWAYS AS (...)
|
||||
# - CREATE INDEX USING GIN on textsearchable_index_col
|
||||
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
|
||||
pass
|
||||
|
||||
# Fail fast: create vector tables at startup so missing pgvector
|
||||
# or embedding provider errors surface immediately
|
||||
if self._semantic_enabled:
|
||||
await self._ensure_vector_tables()
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index or update a single item using UPSERT.
|
||||
@@ -260,6 +260,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
|
||||
logger.info("Ensuring Postgres vector tables exist for semantic search")
|
||||
|
||||
async with self._vector_tables_lock:
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
@@ -349,6 +351,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Postgres vector tables ready (dimensions={self._vector_dimensions})")
|
||||
self._vector_tables_initialized = True
|
||||
|
||||
async def _get_existing_embedding_dims(self, session: AsyncSession) -> int | None:
|
||||
@@ -587,6 +590,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -602,6 +606,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ class SearchRepository(Protocol):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
|
||||
@@ -49,6 +49,7 @@ class SearchRepositoryBase(ABC):
|
||||
# --- Subclass-populated attributes ---
|
||||
_semantic_enabled: bool
|
||||
_semantic_vector_k: int
|
||||
_semantic_min_similarity: float
|
||||
_embedding_provider: Optional[EmbeddingProvider]
|
||||
_vector_dimensions: int
|
||||
_vector_tables_initialized: bool
|
||||
@@ -112,6 +113,7 @@ class SearchRepositoryBase(ABC):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -753,6 +755,7 @@ class SearchRepositoryBase(ABC):
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
retrieval_mode: SearchRetrievalMode,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> Optional[List[SearchIndexRow]]:
|
||||
@@ -784,6 +787,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -802,6 +806,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -830,6 +835,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -843,7 +849,7 @@ class SearchRepositoryBase(ABC):
|
||||
await self._ensure_vector_tables()
|
||||
assert self._embedding_provider is not None
|
||||
query_embedding = await self._embedding_provider.embed_query(search_text.strip())
|
||||
candidate_limit = max(self._semantic_vector_k, (limit + offset) * 5)
|
||||
candidate_limit = max(self._semantic_vector_k, (limit + offset) * 10)
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await self._prepare_vector_session(session)
|
||||
@@ -872,6 +878,18 @@ class SearchRepositoryBase(ABC):
|
||||
if not similarity_by_si_id:
|
||||
return []
|
||||
|
||||
# Filter out results below the minimum similarity threshold.
|
||||
# Per-query min_similarity overrides the instance-level default.
|
||||
effective_min_similarity = (
|
||||
min_similarity if min_similarity is not None else self._semantic_min_similarity
|
||||
)
|
||||
if effective_min_similarity > 0.0:
|
||||
similarity_by_si_id = {
|
||||
k: v for k, v in similarity_by_si_id.items() if v >= effective_min_similarity
|
||||
}
|
||||
if not similarity_by_si_id:
|
||||
return []
|
||||
|
||||
# Fetch the actual search_index rows
|
||||
si_ids = list(similarity_by_si_id.keys())
|
||||
search_index_rows = await self._fetch_search_index_rows_by_ids(si_ids)
|
||||
@@ -1029,6 +1047,7 @@ class SearchRepositoryBase(ABC):
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -1061,26 +1080,39 @@ class SearchRepositoryBase(ABC):
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
min_similarity=min_similarity,
|
||||
limit=candidate_limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# RRF fusion keyed on search_index row id for granular results.
|
||||
# This allows observations and relations to surface as individual results,
|
||||
# not collapsed into their parent entity.
|
||||
# Score-weighted RRF fusion keyed on search_index row id.
|
||||
# Multiplies the standard 1/(k+rank) score by the normalized original score
|
||||
# so that high-confidence matches contribute more than weak ones at the same rank.
|
||||
fused_scores: dict[int, float] = {}
|
||||
rows_by_id: dict[int, SearchIndexRow] = {}
|
||||
|
||||
# Normalize FTS scores to [0, 1] — handles both SQLite (negative bm25)
|
||||
# and Postgres (positive ts_rank) by using absolute values
|
||||
fts_abs = [abs(row.score or 0.0) for row in fts_results]
|
||||
fts_max = max(fts_abs) if fts_abs else 1.0
|
||||
|
||||
for rank, row in enumerate(fts_results, start=1):
|
||||
if row.id is None:
|
||||
continue
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank))
|
||||
norm = abs(row.score or 0.0) / fts_max if fts_max > 0 else 0.0
|
||||
weight = max(norm, 0.1) # floor preserves RRF stability
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + weight * (1.0 / (RRF_K + rank))
|
||||
rows_by_id[row.id] = row
|
||||
|
||||
# Vector scores already in [0, 1] from the similarity formula
|
||||
vec_max = max((row.score or 0.0) for row in vector_results) if vector_results else 1.0
|
||||
|
||||
for rank, row in enumerate(vector_results, start=1):
|
||||
if row.id is None:
|
||||
continue
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + (1.0 / (RRF_K + rank))
|
||||
norm = (row.score or 0.0) / vec_max if vec_max > 0 else 0.0
|
||||
weight = max(norm, 0.1) # floor preserves RRF stability
|
||||
fused_scores[row.id] = fused_scores.get(row.id, 0.0) + weight * (1.0 / (RRF_K + rank))
|
||||
rows_by_id[row.id] = row
|
||||
|
||||
ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True)
|
||||
|
||||
@@ -51,6 +51,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
self._app_config = app_config or ConfigManager().config
|
||||
self._semantic_enabled = self._app_config.semantic_search_enabled
|
||||
self._semantic_vector_k = self._app_config.semantic_vector_k
|
||||
self._semantic_min_similarity = self._app_config.semantic_min_similarity
|
||||
self._embedding_provider = embedding_provider
|
||||
self._sqlite_vec_lock = asyncio.Lock()
|
||||
self._vector_tables_initialized = False
|
||||
@@ -72,7 +73,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
"""Create FTS5 virtual table for search if it doesn't exist.
|
||||
|
||||
Uses CREATE VIRTUAL TABLE IF NOT EXISTS to preserve existing indexed data
|
||||
across server restarts.
|
||||
across server restarts. Also creates vector tables when semantic search
|
||||
is enabled so missing dependencies are caught at startup, not first query.
|
||||
"""
|
||||
logger.info("Initializing SQLite FTS5 search index")
|
||||
try:
|
||||
@@ -84,6 +86,11 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
# Fail fast: create vector tables at startup so missing sqlite-vec
|
||||
# or embedding provider errors surface immediately
|
||||
if self._semantic_enabled:
|
||||
await self._ensure_vector_tables()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FTS5 query preparation (backend-specific)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -367,6 +374,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
if self._vector_tables_initialized:
|
||||
return
|
||||
|
||||
logger.info("Ensuring SQLite vector tables exist for semantic search")
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await self._ensure_sqlite_vec_loaded(session)
|
||||
|
||||
@@ -386,6 +395,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
}
|
||||
schema_mismatch = bool(chunks_columns) and set(chunks_columns) != expected_columns
|
||||
if schema_mismatch:
|
||||
logger.warning("search_vector_chunks schema mismatch, recreating vector tables")
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
|
||||
|
||||
@@ -408,11 +418,16 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
expected_dimension_sql = f"float[{self._vector_dimensions}]"
|
||||
|
||||
if vector_sql and expected_dimension_sql not in vector_sql:
|
||||
logger.warning(
|
||||
f"Embedding dimension mismatch (expected {self._vector_dimensions}), "
|
||||
"recreating search_vector_embeddings"
|
||||
)
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
|
||||
await session.execute(create_sqlite_search_vector_embeddings(self._vector_dimensions))
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"SQLite vector tables ready (dimensions={self._vector_dimensions})")
|
||||
self._vector_tables_initialized = True
|
||||
|
||||
async def _prepare_vector_session(self, session: AsyncSession) -> None:
|
||||
@@ -566,6 +581,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -581,6 +597,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@ class RuntimeMode(Enum):
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
cloud_mode_enabled: bool,
|
||||
is_test_env: bool,
|
||||
) -> RuntimeMode:
|
||||
"""Resolve the runtime mode from configuration flags.
|
||||
@@ -40,7 +39,6 @@ def resolve_runtime_mode(
|
||||
Composition roots call this with config values they've read.
|
||||
|
||||
Args:
|
||||
cloud_mode_enabled: Whether cloud mode is enabled in config
|
||||
is_test_env: Whether running in test environment
|
||||
|
||||
Returns:
|
||||
@@ -52,10 +50,4 @@ def resolve_runtime_mode(
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
|
||||
# Trigger: cloud mode is enabled in config
|
||||
# Why: cloud mode changes auth, sync, and API behavior
|
||||
# Outcome: returns CLOUD mode for remote-first behavior
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
|
||||
return RuntimeMode.LOCAL
|
||||
|
||||
@@ -48,3 +48,28 @@ class CloudProjectCreateResponse(BaseModel):
|
||||
new_project: dict | None = Field(
|
||||
None, description="Information about the newly created project"
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInfo(BaseModel):
|
||||
"""Workspace entry from /workspaces/ endpoint."""
|
||||
|
||||
tenant_id: str = Field(..., description="Workspace tenant identifier")
|
||||
workspace_type: str = Field(..., description="Workspace type (personal or organization)")
|
||||
name: str = Field(..., description="Workspace display name")
|
||||
role: str = Field(..., description="Current user's role in the workspace")
|
||||
organization_id: str | None = Field(None, description="Organization ID for org workspaces")
|
||||
has_active_subscription: bool = Field(
|
||||
default=False, description="Whether the workspace has an active subscription"
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceListResponse(BaseModel):
|
||||
"""Response from /workspaces/ endpoint."""
|
||||
|
||||
workspaces: list[WorkspaceInfo] = Field(
|
||||
default_factory=list, description="Available workspaces"
|
||||
)
|
||||
count: int = Field(default=0, description="Number of available workspaces")
|
||||
current_workspace_id: str | None = Field(
|
||||
default=None, description="Current workspace tenant ID when available"
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ class ProjectInfoResponse(BaseModel):
|
||||
available_projects: Dict[str, Dict[str, Any]] = Field(
|
||||
description="Map of configured project names to detailed project information"
|
||||
)
|
||||
default_project: str = Field(description="Name of the default project")
|
||||
default_project: Optional[str] = Field(description="Name of the default project")
|
||||
|
||||
# Statistics
|
||||
statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
|
||||
@@ -196,7 +196,7 @@ class ProjectList(BaseModel):
|
||||
"""Response model for listing projects."""
|
||||
|
||||
projects: List[ProjectItem]
|
||||
default_project: str
|
||||
default_project: Optional[str]
|
||||
|
||||
|
||||
class ProjectStatusResponse(BaseModel):
|
||||
|
||||
@@ -195,6 +195,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
entity_metadata: Optional[Dict] = None
|
||||
checksum: Optional[str] = None
|
||||
content_type: ContentType
|
||||
external_id: Optional[str] = None
|
||||
observations: List[ObservationResponse] = []
|
||||
relations: List[RelationResponse] = []
|
||||
created_at: datetime
|
||||
|
||||
@@ -47,6 +47,7 @@ class ValidationReport(BaseModel):
|
||||
|
||||
entity_type: str | None = None
|
||||
total_notes: int = 0
|
||||
total_entities: int = 0
|
||||
valid_count: int = 0
|
||||
warning_count: int = 0
|
||||
error_count: int = 0
|
||||
@@ -110,6 +111,10 @@ class DriftReport(BaseModel):
|
||||
"""Schema drift analysis comparing schema definition to actual usage."""
|
||||
|
||||
entity_type: str
|
||||
schema_found: bool = Field(
|
||||
default=True,
|
||||
description="Whether a schema was found for this type",
|
||||
)
|
||||
new_fields: list[DriftFieldResponse] = Field(
|
||||
default_factory=list,
|
||||
description="Fields common in notes but not in schema",
|
||||
|
||||
@@ -68,6 +68,7 @@ class SearchQuery(BaseModel):
|
||||
tags: Optional[List[str]] = None # Convenience tag filter
|
||||
status: Optional[str] = None # Convenience status filter
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS
|
||||
min_similarity: Optional[float] = None # Per-query override for semantic_min_similarity
|
||||
|
||||
@field_validator("after_date")
|
||||
@classmethod
|
||||
|
||||
@@ -539,9 +539,7 @@ class ContextService:
|
||||
{relation_date_filter}
|
||||
{relation_project_filter}
|
||||
)
|
||||
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
|
||||
WHERE eg.depth < :max_depth
|
||||
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
|
||||
|
||||
UNION ALL
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.models import Observation, Relation
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.repository import ObservationRepository, RelationRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.schemas.base import Permalink
|
||||
@@ -41,7 +42,7 @@ from basic_memory.services.exceptions import (
|
||||
)
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.utils import build_canonical_permalink
|
||||
|
||||
|
||||
class EntityService(BaseService[EntityModel]):
|
||||
@@ -66,6 +67,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
self.link_resolver = link_resolver
|
||||
self.search_service = search_service
|
||||
self.app_config = app_config
|
||||
self._project_permalink: Optional[str] = None
|
||||
|
||||
async def detect_file_path_conflicts(
|
||||
self, file_path: str, skip_check: bool = False
|
||||
@@ -159,7 +161,23 @@ class EntityService(BaseService[EntityModel]):
|
||||
if markdown and markdown.frontmatter.permalink:
|
||||
desired_permalink = markdown.frontmatter.permalink
|
||||
else:
|
||||
desired_permalink = generate_permalink(file_path_str)
|
||||
# Trigger: generating a permalink for a new file
|
||||
# Why: canonical permalinks may require project prefix for global addressing
|
||||
# Outcome: include project slug when enabled in config
|
||||
include_project = True
|
||||
if self.app_config:
|
||||
include_project = self.app_config.permalinks_include_project
|
||||
|
||||
project_permalink = None
|
||||
# Trigger: project-prefixed permalinks are enabled
|
||||
# Why: we need the project slug to build the canonical permalink
|
||||
# Outcome: fetch and cache the project's permalink
|
||||
if include_project:
|
||||
project_permalink = await self._get_project_permalink()
|
||||
|
||||
desired_permalink = build_canonical_permalink(
|
||||
project_permalink, file_path_str, include_project=include_project
|
||||
)
|
||||
|
||||
# Make unique if needed - enhanced to handle character conflicts
|
||||
# Use lightweight existence check instead of loading full entity
|
||||
@@ -172,6 +190,21 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
return permalink
|
||||
|
||||
async def _get_project_permalink(self) -> Optional[str]:
|
||||
"""Get and cache the current project's permalink."""
|
||||
if self._project_permalink is not None:
|
||||
return self._project_permalink
|
||||
|
||||
project_id = self.repository.project_id
|
||||
if project_id is None: # pragma: no cover
|
||||
return None # pragma: no cover
|
||||
|
||||
project_repository = ProjectRepository(self.repository.session_maker)
|
||||
project = await project_repository.get_by_id(project_id)
|
||||
if project:
|
||||
self._project_permalink = project.permalink
|
||||
return self._project_permalink
|
||||
|
||||
def _build_frontmatter_markdown(
|
||||
self, title: str, entity_type: str, permalink: str
|
||||
) -> EntityMarkdown:
|
||||
@@ -313,9 +346,12 @@ class EntityService(BaseService[EntityModel]):
|
||||
# Merge new metadata with existing metadata
|
||||
existing_markdown.frontmatter.metadata.update(post.metadata)
|
||||
|
||||
# Ensure the permalink in the metadata is the resolved one
|
||||
if new_permalink != entity.permalink:
|
||||
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
|
||||
# Always ensure the permalink in the metadata is the canonical one from the database.
|
||||
# The schema_to_markdown call above uses EntitySchema.permalink which computes a
|
||||
# non-prefixed permalink (e.g., "test/note"). The metadata merge on the previous line
|
||||
# would overwrite the project-prefixed permalink (e.g., "project/test/note") stored
|
||||
# in the existing file. Setting it unconditionally preserves the correct value.
|
||||
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
|
||||
|
||||
# Create a new post with merged metadata
|
||||
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectMode
|
||||
from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectMode
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository import (
|
||||
ProjectRepository,
|
||||
@@ -174,9 +174,23 @@ async def initialize_app(
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
"""
|
||||
# Skip initialization in cloud mode - cloud manages its own projects
|
||||
if app_config.cloud_mode_enabled:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
# Trigger: frontmatter enforcement is enabled while permalink generation is disabled
|
||||
# Why: missing-frontmatter sync path needs canonical permalinks for deterministic indexing
|
||||
# Outcome: log startup precedence so behavior is explicit to operators
|
||||
if app_config.ensure_frontmatter_on_sync and app_config.disable_permalinks:
|
||||
logger.warning(
|
||||
"Config precedence: ensure_frontmatter_on_sync=True overrides "
|
||||
"disable_permalinks=True for markdown files missing frontmatter during sync; "
|
||||
"permalinks will be written."
|
||||
)
|
||||
|
||||
# Trigger: database backend is Postgres (cloud deployment)
|
||||
# Why: cloud deployments manage their own projects and migrations via the cloud platform.
|
||||
# The local MCP server always uses SQLite and needs initialization even when
|
||||
# projects are configured for cloud routing.
|
||||
# Outcome: skip initialization only for actual cloud Postgres deployments.
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
logger.info("Skipping local initialization - Postgres backend manages its own schema")
|
||||
return
|
||||
|
||||
logger.info("Initializing app...")
|
||||
@@ -186,7 +200,7 @@ async def initialize_app(
|
||||
# Reconcile projects from config.json with projects table
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
logger.info("App initialization completed (migration running in background if needed)")
|
||||
logger.info("App initialization completed")
|
||||
|
||||
|
||||
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
@@ -195,14 +209,13 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
This is a wrapper for the async initialize_app function that can be
|
||||
called from synchronous code like CLI entry points.
|
||||
|
||||
No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
|
||||
No-op if database backend is Postgres (cloud deployment manages its own schema).
|
||||
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
"""
|
||||
# Skip initialization in cloud mode - cloud manages its own projects
|
||||
if app_config.cloud_mode_enabled:
|
||||
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
logger.info("Skipping local initialization - Postgres backend manages its own schema")
|
||||
return
|
||||
|
||||
async def _init_and_cleanup():
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"""Service for resolving markdown links to permalinks."""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import uuid as uuid_mod
|
||||
from typing import Optional, Tuple, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.models import Entity, Project
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.search_repository import create_search_repository
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import (
|
||||
build_canonical_permalink,
|
||||
generate_permalink,
|
||||
normalize_project_reference,
|
||||
)
|
||||
|
||||
|
||||
class LinkResolver:
|
||||
@@ -26,6 +34,12 @@ class LinkResolver:
|
||||
"""Initialize with repositories."""
|
||||
self.entity_repository = entity_repository
|
||||
self.search_service = search_service
|
||||
self._project_repository = ProjectRepository(entity_repository.session_maker)
|
||||
self._app_config: BasicMemoryConfig = ConfigManager().config
|
||||
self._project_permalink: Optional[str] = None
|
||||
self._project_cache_by_identifier: Dict[str, Project] = {}
|
||||
self._entity_repository_cache: Dict[int, EntityRepository] = {}
|
||||
self._search_service_cache: Dict[int, SearchService] = {}
|
||||
|
||||
async def resolve_link(
|
||||
self,
|
||||
@@ -47,111 +61,82 @@ class LinkResolver:
|
||||
|
||||
# Clean link text and extract any alias
|
||||
clean_text, alias = self._normalize_link_text(link_text)
|
||||
explicit_project_reference = "::" in clean_text
|
||||
clean_text = normalize_project_reference(clean_text)
|
||||
|
||||
# --- Path Resolution ---
|
||||
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
|
||||
# for cross-platform compatibility. See entity_repository.py which normalizes
|
||||
# paths using Path().as_posix(). This allows consistent path operations here.
|
||||
# --- External ID Resolution ---
|
||||
# Try external_id first if identifier looks like a UUID.
|
||||
# Canonicalize to lowercase-hyphen form so uppercase or unhyphenated
|
||||
# UUIDs also match the stored external_id values.
|
||||
try:
|
||||
canonical_id = str(uuid_mod.UUID(clean_text))
|
||||
entity = await self.entity_repository.get_by_external_id(canonical_id)
|
||||
if entity:
|
||||
logger.debug(f"Found entity by external_id: {entity.permalink}")
|
||||
return entity
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# --- Relative Path Resolution ---
|
||||
# Trigger: source_path is provided AND link contains "/"
|
||||
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
|
||||
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
|
||||
if source_path and "/" in clean_text:
|
||||
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
|
||||
if source_folder:
|
||||
# Construct relative path from source folder
|
||||
relative_path = f"{source_folder}/{clean_text}"
|
||||
# Trigger: link uses project namespace syntax (project::note)
|
||||
# Why: treat it as an explicit cross-project reference
|
||||
# Outcome: resolve only within the referenced project scope
|
||||
if explicit_project_reference:
|
||||
project_prefix, remainder = self._split_project_prefix(clean_text)
|
||||
if not project_prefix:
|
||||
return None
|
||||
|
||||
# Try with .md extension
|
||||
if not relative_path.endswith(".md"):
|
||||
relative_path_md = f"{relative_path}.md"
|
||||
entity = await self.entity_repository.get_by_file_path(relative_path_md)
|
||||
if entity:
|
||||
return entity
|
||||
project_resources = await self._get_project_resources(project_prefix)
|
||||
if not project_resources:
|
||||
return None
|
||||
|
||||
# Try as-is (already has extension or is a permalink)
|
||||
entity = await self.entity_repository.get_by_file_path(relative_path)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
# When source_path is provided, use context-aware resolution:
|
||||
# Check both permalink and title matches, prefer closest to source.
|
||||
# Example: [[testing]] from folder/note.md prefers folder/testing.md
|
||||
# over a root testing.md with permalink "testing".
|
||||
if source_path:
|
||||
# Gather all potential matches
|
||||
candidates: list[Entity] = []
|
||||
|
||||
# Check permalink match
|
||||
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
|
||||
if permalink_entity:
|
||||
candidates.append(permalink_entity)
|
||||
|
||||
# Check title matches
|
||||
title_entities = await self.entity_repository.get_by_title(clean_text)
|
||||
for entity in title_entities:
|
||||
# Avoid duplicates (permalink match might also be in title matches)
|
||||
if entity.id not in [c.id for c in candidates]:
|
||||
candidates.append(entity)
|
||||
|
||||
if candidates:
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
else:
|
||||
# Multiple candidates - pick closest to source
|
||||
return self._find_closest_entity(candidates, source_path)
|
||||
|
||||
# Standard resolution (no source context): permalink first, then title
|
||||
# 1. Try exact permalink match first (most efficient)
|
||||
entity = await self.entity_repository.get_by_permalink(clean_text)
|
||||
if entity:
|
||||
logger.debug(f"Found exact permalink match: {entity.permalink}")
|
||||
return entity
|
||||
|
||||
# 2. Try exact title match
|
||||
found = await self.entity_repository.get_by_title(clean_text)
|
||||
if found:
|
||||
# Return first match (shortest path) if no source context
|
||||
entity = found[0]
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
|
||||
# 3. Try file path
|
||||
found_path = await self.entity_repository.get_by_file_path(clean_text)
|
||||
if found_path:
|
||||
logger.debug(f"Found entity with path: {found_path.file_path}")
|
||||
return found_path
|
||||
|
||||
# 4. Try file path with .md extension if not already present
|
||||
if not clean_text.endswith(".md") and "/" in clean_text:
|
||||
file_path_with_md = f"{clean_text}.md"
|
||||
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
|
||||
if found_path_md:
|
||||
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
||||
return found_path_md
|
||||
|
||||
# In strict mode, don't try fuzzy search - return None if no exact match found
|
||||
if strict:
|
||||
return None
|
||||
|
||||
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
|
||||
if use_search and "*" not in clean_text:
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
project, entity_repository, search_service = project_resources
|
||||
return await self._resolve_in_project(
|
||||
entity_repository=entity_repository,
|
||||
search_service=search_service,
|
||||
link_text=remainder,
|
||||
use_search=use_search,
|
||||
strict=strict,
|
||||
source_path=None,
|
||||
project_permalink=project.permalink,
|
||||
)
|
||||
|
||||
if results:
|
||||
# Look for best match
|
||||
best_match = min(results, key=lambda x: x.score) # pyright: ignore
|
||||
logger.trace(
|
||||
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
||||
)
|
||||
if best_match.permalink:
|
||||
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
||||
current_project_permalink = await self._get_current_project_permalink()
|
||||
resolved = await self._resolve_in_project(
|
||||
entity_repository=self.entity_repository,
|
||||
search_service=self.search_service,
|
||||
link_text=clean_text,
|
||||
use_search=use_search,
|
||||
strict=strict,
|
||||
source_path=source_path,
|
||||
project_permalink=current_project_permalink,
|
||||
)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# if we couldn't find anything then return None
|
||||
return None
|
||||
# Trigger: local resolution failed and identifier looks like project/path
|
||||
# Why: allow explicit project path references without namespace syntax
|
||||
# Outcome: attempt resolution in the referenced project if it exists
|
||||
project_prefix, remainder = self._split_project_prefix(clean_text)
|
||||
if not project_prefix:
|
||||
return None
|
||||
|
||||
project_resources = await self._get_project_resources(project_prefix)
|
||||
if not project_resources:
|
||||
return None
|
||||
|
||||
project, entity_repository, search_service = project_resources
|
||||
if project.id == self.entity_repository.project_id:
|
||||
return None
|
||||
|
||||
return await self._resolve_in_project(
|
||||
entity_repository=entity_repository,
|
||||
search_service=search_service,
|
||||
link_text=remainder,
|
||||
use_search=use_search,
|
||||
strict=strict,
|
||||
source_path=None,
|
||||
project_permalink=project.permalink,
|
||||
)
|
||||
|
||||
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
||||
"""Normalize link text and extract alias if present.
|
||||
@@ -181,6 +166,230 @@ class LinkResolver:
|
||||
|
||||
return text, alias
|
||||
|
||||
async def _resolve_in_project(
|
||||
self,
|
||||
*,
|
||||
entity_repository: EntityRepository,
|
||||
search_service: SearchService,
|
||||
link_text: str,
|
||||
use_search: bool,
|
||||
strict: bool,
|
||||
source_path: Optional[str],
|
||||
project_permalink: Optional[str],
|
||||
) -> Optional[Entity]:
|
||||
"""Resolve a link within a specific project scope."""
|
||||
clean_text = link_text
|
||||
include_project = self._include_project_permalinks()
|
||||
|
||||
canonical_permalink: Optional[str] = None
|
||||
legacy_permalink: Optional[str] = None
|
||||
# Trigger: permalinks include project slug and project permalink is known
|
||||
# Why: support globally addressable permalinks while keeping legacy links resolvable
|
||||
# Outcome: include canonical and legacy candidates for resolution
|
||||
if include_project and project_permalink:
|
||||
canonical_permalink = build_canonical_permalink(
|
||||
project_permalink, clean_text, include_project=True
|
||||
)
|
||||
if clean_text.startswith(f"{project_permalink}/"):
|
||||
legacy_candidate = clean_text.removeprefix(f"{project_permalink}/")
|
||||
if legacy_candidate:
|
||||
legacy_permalink = legacy_candidate
|
||||
|
||||
permalink_candidates = []
|
||||
for candidate in (clean_text, canonical_permalink, legacy_permalink):
|
||||
if candidate and candidate not in permalink_candidates:
|
||||
permalink_candidates.append(candidate)
|
||||
|
||||
# --- Path Resolution ---
|
||||
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
|
||||
# for cross-platform compatibility. See entity_repository.py which normalizes
|
||||
# paths using Path().as_posix(). This allows consistent path operations here.
|
||||
|
||||
# --- Relative Path Resolution ---
|
||||
# Trigger: source_path is provided AND link contains "/"
|
||||
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
|
||||
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
|
||||
if source_path and "/" in clean_text:
|
||||
if not (
|
||||
include_project
|
||||
and project_permalink
|
||||
and clean_text.startswith(f"{project_permalink}/")
|
||||
):
|
||||
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
|
||||
if source_folder:
|
||||
# Construct relative path from source folder
|
||||
relative_path = f"{source_folder}/{clean_text}"
|
||||
|
||||
# Try with .md extension
|
||||
if not relative_path.endswith(".md"):
|
||||
relative_path_md = f"{relative_path}.md"
|
||||
entity = await entity_repository.get_by_file_path(relative_path_md)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
# Try as-is (already has extension or is a permalink)
|
||||
entity = await entity_repository.get_by_file_path(relative_path)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
# When source_path is provided, use context-aware resolution:
|
||||
# Check both permalink and title matches, prefer closest to source.
|
||||
# Example: [[testing]] from folder/note.md prefers folder/testing.md
|
||||
# over a root testing.md with permalink "testing".
|
||||
if source_path:
|
||||
# Gather all potential matches
|
||||
candidates: list[Entity] = []
|
||||
|
||||
# Check permalink match
|
||||
for candidate_permalink in permalink_candidates:
|
||||
permalink_entity = await entity_repository.get_by_permalink(candidate_permalink)
|
||||
if permalink_entity and permalink_entity.id not in [c.id for c in candidates]:
|
||||
candidates.append(permalink_entity)
|
||||
|
||||
# Check title matches
|
||||
title_entities = await entity_repository.get_by_title(clean_text)
|
||||
for entity in title_entities:
|
||||
# Avoid duplicates (permalink match might also be in title matches)
|
||||
if entity.id not in [c.id for c in candidates]:
|
||||
candidates.append(entity)
|
||||
|
||||
if candidates:
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
else:
|
||||
# Multiple candidates - pick closest to source
|
||||
return self._find_closest_entity(candidates, source_path)
|
||||
|
||||
# Standard resolution (no source context): permalink first, then title
|
||||
# 1. Try exact permalink match first (most efficient)
|
||||
for candidate_permalink in permalink_candidates:
|
||||
entity = await entity_repository.get_by_permalink(candidate_permalink)
|
||||
if entity:
|
||||
logger.debug(f"Found exact permalink match: {entity.permalink}")
|
||||
return entity
|
||||
|
||||
# 2. Try exact title match
|
||||
found = await entity_repository.get_by_title(clean_text)
|
||||
if found:
|
||||
# Return first match (shortest path) if no source context
|
||||
entity = found[0]
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
|
||||
# 3. Try file path
|
||||
found_path = await entity_repository.get_by_file_path(clean_text)
|
||||
if found_path:
|
||||
logger.debug(f"Found entity with path: {found_path.file_path}")
|
||||
return found_path
|
||||
|
||||
# 4. Try file path with .md extension if not already present
|
||||
if not clean_text.endswith(".md") and "/" in clean_text:
|
||||
file_path_with_md = f"{clean_text}.md"
|
||||
found_path_md = await entity_repository.get_by_file_path(file_path_with_md)
|
||||
if found_path_md:
|
||||
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
||||
return found_path_md
|
||||
|
||||
# In strict mode, don't try fuzzy search - return None if no exact match found
|
||||
if strict:
|
||||
return None
|
||||
|
||||
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
|
||||
if use_search and "*" not in clean_text:
|
||||
results = await search_service.search(
|
||||
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
|
||||
if results:
|
||||
# Look for best match
|
||||
best_match = min(results, key=lambda x: x.score) # pyright: ignore
|
||||
logger.trace(
|
||||
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
||||
)
|
||||
if best_match.permalink:
|
||||
return await entity_repository.get_by_permalink(best_match.permalink)
|
||||
|
||||
# if we couldn't find anything then return None
|
||||
return None
|
||||
|
||||
def _include_project_permalinks(self) -> bool:
|
||||
"""Return True when permalinks should include the project slug."""
|
||||
return self._app_config.permalinks_include_project
|
||||
|
||||
async def _get_current_project_permalink(self) -> Optional[str]:
|
||||
"""Get and cache the current project's permalink."""
|
||||
if self._project_permalink is not None:
|
||||
return self._project_permalink
|
||||
|
||||
project_id = self.entity_repository.project_id
|
||||
if project_id is None: # pragma: no cover
|
||||
return None # pragma: no cover
|
||||
|
||||
project = await self._project_repository.get_by_id(project_id)
|
||||
if project:
|
||||
self._project_permalink = project.permalink
|
||||
return self._project_permalink
|
||||
|
||||
async def _get_project_by_identifier(self, identifier: str) -> Optional[Project]:
|
||||
"""Resolve project by name or permalink."""
|
||||
cache_key = identifier.strip().lower()
|
||||
if cache_key in self._project_cache_by_identifier:
|
||||
return self._project_cache_by_identifier[cache_key]
|
||||
|
||||
project = await self._project_repository.get_by_name(identifier)
|
||||
if not project:
|
||||
project = await self._project_repository.get_by_name_case_insensitive(identifier)
|
||||
if not project:
|
||||
project = await self._project_repository.get_by_permalink(
|
||||
generate_permalink(identifier)
|
||||
)
|
||||
|
||||
if project:
|
||||
self._project_cache_by_identifier[cache_key] = project
|
||||
return project
|
||||
|
||||
async def _get_project_resources(
|
||||
self, project_identifier: str
|
||||
) -> Optional[Tuple[Project, EntityRepository, SearchService]]:
|
||||
"""Fetch repositories and services scoped to a project."""
|
||||
project = await self._get_project_by_identifier(project_identifier)
|
||||
if not project:
|
||||
return None
|
||||
|
||||
entity_repository = self._entity_repository_cache.get(project.id)
|
||||
if not entity_repository:
|
||||
entity_repository = EntityRepository(
|
||||
self.entity_repository.session_maker, project_id=project.id
|
||||
)
|
||||
self._entity_repository_cache[project.id] = entity_repository
|
||||
|
||||
search_service = self._search_service_cache.get(project.id)
|
||||
if not search_service:
|
||||
search_repository = create_search_repository(
|
||||
self.entity_repository.session_maker,
|
||||
project_id=project.id,
|
||||
database_backend=self._app_config.database_backend,
|
||||
)
|
||||
search_service = SearchService(
|
||||
search_repository,
|
||||
entity_repository,
|
||||
self.search_service.file_service,
|
||||
)
|
||||
self._search_service_cache[project.id] = search_service
|
||||
|
||||
return project, entity_repository, search_service
|
||||
|
||||
def _split_project_prefix(self, identifier: str) -> Tuple[Optional[str], str]:
|
||||
"""Split project prefix from a path-like identifier."""
|
||||
if "/" not in identifier:
|
||||
return None, identifier
|
||||
|
||||
project_prefix, remainder = identifier.split("/", 1)
|
||||
if not project_prefix or not remainder:
|
||||
return None, identifier
|
||||
|
||||
return project_prefix, remainder
|
||||
|
||||
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
|
||||
"""Find the entity closest to the source file path.
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@ from basic_memory.schemas import (
|
||||
ProjectStatistics,
|
||||
SystemStatus,
|
||||
)
|
||||
from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
|
||||
from basic_memory.config import (
|
||||
DatabaseBackend,
|
||||
WATCH_STATUS_JSON,
|
||||
ConfigManager,
|
||||
ProjectEntry,
|
||||
get_project_config,
|
||||
ProjectConfig,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -62,20 +69,20 @@ class ProjectService:
|
||||
return self.config_manager.projects
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
def default_project(self) -> Optional[str]:
|
||||
"""Get the name of the default project.
|
||||
|
||||
Returns:
|
||||
The name of the default project
|
||||
The name of the default project, or None if not set
|
||||
"""
|
||||
return self.config_manager.default_project
|
||||
|
||||
@property
|
||||
def current_project(self) -> str:
|
||||
def current_project(self) -> Optional[str]:
|
||||
"""Get the name of the currently active project.
|
||||
|
||||
Returns:
|
||||
The name of the current project
|
||||
The name of the current project, or None if not set
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
|
||||
|
||||
@@ -198,9 +205,9 @@ class ProjectService:
|
||||
f"Projects cannot share directory trees."
|
||||
)
|
||||
|
||||
if not self.config_manager.config.cloud_mode:
|
||||
# First add to config file (this will validate the project doesn't exist)
|
||||
self.config_manager.add_project(name, resolved_path)
|
||||
# First add to config file (this validates project uniqueness and keeps
|
||||
# config + database aligned for all backends).
|
||||
self.config_manager.add_project(name, resolved_path)
|
||||
|
||||
# Then add to database
|
||||
project_data = {
|
||||
@@ -245,7 +252,7 @@ class ProjectService:
|
||||
# In cloud mode: database is source of truth
|
||||
# In local mode: also check config file
|
||||
is_default = project.is_default
|
||||
if not self.config_manager.config.cloud_mode:
|
||||
if self.config_manager.config.database_backend != DatabaseBackend.POSTGRES:
|
||||
is_default = is_default or name == self.config_manager.config.default_project
|
||||
if is_default:
|
||||
raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
|
||||
@@ -300,9 +307,8 @@ class ProjectService:
|
||||
# Update database
|
||||
await self.repository.set_as_default(project.id)
|
||||
|
||||
# Update config file only in local mode (cloud mode uses database only)
|
||||
if not self.config_manager.config.cloud_mode:
|
||||
self.config_manager.set_default_project(name)
|
||||
# Keep config and database default project in sync for all backends.
|
||||
self.config_manager.set_default_project(name)
|
||||
|
||||
logger.info(f"Project '{name}' set as default in configuration and database")
|
||||
|
||||
@@ -340,7 +346,9 @@ class ProjectService:
|
||||
# No default project - set the config default as default
|
||||
# This is defensive code for edge cases where no default exists
|
||||
config_default = self.config_manager.default_project # pragma: no cover
|
||||
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
|
||||
config_project = (
|
||||
await self.repository.get_by_name(config_default) if config_default else None
|
||||
) # pragma: no cover
|
||||
if config_project: # pragma: no cover
|
||||
await self.repository.set_as_default(config_project.id) # pragma: no cover
|
||||
logger.info(
|
||||
@@ -364,11 +372,12 @@ class ProjectService:
|
||||
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
||||
|
||||
# Get all projects from configuration and normalize names if needed
|
||||
config_projects = self.config_manager.projects.copy()
|
||||
updated_config = {}
|
||||
# Use .config property (not load_config()) so tests can patch ConfigManager.config
|
||||
config = self.config_manager.config
|
||||
updated_config: Dict[str, ProjectEntry] = {}
|
||||
config_updated = False
|
||||
|
||||
for name, path in config_projects.items():
|
||||
for name, entry in config.projects.items():
|
||||
# Generate normalized name (what the database expects)
|
||||
normalized_name = generate_permalink(name)
|
||||
|
||||
@@ -376,25 +385,24 @@ class ProjectService:
|
||||
logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'")
|
||||
config_updated = True
|
||||
|
||||
updated_config[normalized_name] = path
|
||||
updated_config[normalized_name] = entry
|
||||
|
||||
# Update the configuration if any changes were made
|
||||
if config_updated:
|
||||
config = self.config_manager.load_config()
|
||||
config.projects = updated_config
|
||||
self.config_manager.save_config(config)
|
||||
logger.info("Config updated with normalized project names")
|
||||
|
||||
# Use the normalized config for further processing
|
||||
config_projects = updated_config
|
||||
# Use the normalized config for further processing — keys are now project names
|
||||
config_project_names = updated_config
|
||||
|
||||
# Add projects that exist in config but not in DB
|
||||
for name, path in config_projects.items():
|
||||
for name, entry in config_project_names.items():
|
||||
if name not in db_projects_by_permalink:
|
||||
logger.info(f"Adding project '{name}' to database")
|
||||
project_data = {
|
||||
"name": name,
|
||||
"path": path,
|
||||
"path": entry.path,
|
||||
"permalink": generate_permalink(name),
|
||||
"is_active": True,
|
||||
# Don't set is_default here - let the enforcement logic handle it
|
||||
@@ -405,7 +413,7 @@ class ProjectService:
|
||||
# Config is the source of truth - if a project was deleted from config,
|
||||
# it should be deleted from DB too (fixes issue #193)
|
||||
for name, project in db_projects_by_permalink.items():
|
||||
if name not in config_projects:
|
||||
if name not in config_project_names:
|
||||
logger.info(
|
||||
f"Removing project '{name}' from database (deleted from config, source of truth)"
|
||||
)
|
||||
@@ -456,8 +464,8 @@ class ProjectService:
|
||||
|
||||
# Update in configuration
|
||||
config = self.config_manager.load_config()
|
||||
old_path = config.projects[name]
|
||||
config.projects[name] = resolved_path
|
||||
old_path = config.projects[name].path
|
||||
config.projects[name].path = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database using robust lookup
|
||||
@@ -468,7 +476,7 @@ class ProjectService:
|
||||
else:
|
||||
logger.error(f"Project '{name}' exists in config but not in database")
|
||||
# Restore the old path in config since DB update failed
|
||||
config.projects[name] = old_path
|
||||
config.projects[name].path = old_path
|
||||
self.config_manager.save_config(config)
|
||||
raise ValueError(f"Project '{name}' not found in database")
|
||||
|
||||
@@ -504,7 +512,7 @@ class ProjectService:
|
||||
|
||||
# Update in config
|
||||
config = self.config_manager.load_config()
|
||||
config.projects[name] = resolved_path
|
||||
config.projects[name].path = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database
|
||||
|
||||
@@ -21,6 +21,42 @@ from basic_memory.services import FileService
|
||||
# We use 6000 characters to leave headroom for other indexed columns and overhead.
|
||||
MAX_CONTENT_STEMS_SIZE = 6000
|
||||
|
||||
# Common glue words used to relax natural-language FTS queries after strict misses.
|
||||
FTS_RELAXED_STOPWORDS = {
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"are",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"by",
|
||||
"for",
|
||||
"from",
|
||||
"how",
|
||||
"in",
|
||||
"is",
|
||||
"it",
|
||||
"of",
|
||||
"on",
|
||||
"or",
|
||||
"our",
|
||||
"the",
|
||||
"their",
|
||||
"this",
|
||||
"to",
|
||||
"was",
|
||||
"we",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"who",
|
||||
"why",
|
||||
"with",
|
||||
"you",
|
||||
"your",
|
||||
}
|
||||
|
||||
|
||||
def _mtime_to_datetime(entity: Entity) -> datetime:
|
||||
"""Convert entity mtime (file modification time) to datetime.
|
||||
@@ -124,9 +160,12 @@ class SearchService:
|
||||
if query.status:
|
||||
metadata_filters.setdefault("status", query.status)
|
||||
|
||||
# search
|
||||
retrieval_mode = query.retrieval_mode or SearchRetrievalMode.FTS
|
||||
strict_search_text = query.text
|
||||
|
||||
# First pass: preserve existing strict search behavior.
|
||||
results = await self.repository.search(
|
||||
search_text=query.text,
|
||||
search_text=strict_search_text,
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
@@ -134,12 +173,96 @@ class SearchService:
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=query.retrieval_mode or SearchRetrievalMode.FTS,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=query.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return results
|
||||
# Trigger: strict FTS with plain multi-term text returned no results.
|
||||
# Why: natural-language queries often include stopwords that over-constrain implicit AND.
|
||||
# Outcome: retry once with relaxed OR terms while preserving explicit boolean intent.
|
||||
if results:
|
||||
return results
|
||||
if not self._is_relaxed_fts_fallback_eligible(query, strict_search_text, retrieval_mode):
|
||||
return results
|
||||
|
||||
assert strict_search_text is not None
|
||||
relaxed_search_text = self._build_relaxed_fts_query(strict_search_text)
|
||||
if relaxed_search_text == strict_search_text:
|
||||
return results
|
||||
|
||||
logger.debug(
|
||||
"Strict FTS returned 0 results; retrying relaxed FTS query "
|
||||
f"strict='{strict_search_text}' relaxed='{relaxed_search_text}'"
|
||||
)
|
||||
return await self.repository.search(
|
||||
search_text=relaxed_search_text,
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
types=query.types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=query.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tokenize_fts_text(search_text: str) -> list[str]:
|
||||
"""Tokenize text into alphanumeric terms for relaxed FTS fallback."""
|
||||
return re.findall(r"[A-Za-z0-9]+", search_text.lower())
|
||||
|
||||
@classmethod
|
||||
def _build_relaxed_fts_query(cls, search_text: str) -> str:
|
||||
"""Build a less strict OR query from natural-language input."""
|
||||
normalized_terms = cls._tokenize_fts_text(search_text)
|
||||
if not normalized_terms:
|
||||
return search_text
|
||||
|
||||
deduped_terms: list[str] = []
|
||||
seen_terms: set[str] = set()
|
||||
for term in normalized_terms:
|
||||
if term in seen_terms:
|
||||
continue
|
||||
seen_terms.add(term)
|
||||
deduped_terms.append(term)
|
||||
|
||||
pruned_terms = [term for term in deduped_terms if term not in FTS_RELAXED_STOPWORDS]
|
||||
relaxed_terms = pruned_terms or deduped_terms
|
||||
return " OR ".join(relaxed_terms)
|
||||
|
||||
@classmethod
|
||||
def _is_relaxed_fts_fallback_eligible(
|
||||
cls,
|
||||
query: SearchQuery,
|
||||
search_text: str | None,
|
||||
retrieval_mode: SearchRetrievalMode,
|
||||
) -> bool:
|
||||
"""Check whether we should run relaxed OR fallback after strict FTS returns empty."""
|
||||
if retrieval_mode != SearchRetrievalMode.FTS:
|
||||
return False
|
||||
if not search_text or not search_text.strip():
|
||||
return False
|
||||
if '"' in search_text:
|
||||
return False
|
||||
if query.has_boolean_operators():
|
||||
return False
|
||||
tokens = cls._tokenize_fts_text(search_text)
|
||||
# Trigger: query has only one or two terms (e.g., link titles like "New Feature").
|
||||
# Why: OR-relaxing short queries can over-broaden and produce false positives.
|
||||
# Outcome: require at least three tokens before enabling relaxed fallback.
|
||||
if len(tokens) < 3:
|
||||
return False
|
||||
# Trigger: query contains explicit numeric identifiers (e.g., "root note 1").
|
||||
# Why: OR-relaxing identifier-like queries can over-broaden and create false positives.
|
||||
# Outcome: preserve strict matching for these targeted queries.
|
||||
if any(token.isdigit() for token in tokens):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _generate_variants(text: str) -> Set[str]:
|
||||
|
||||
@@ -669,6 +669,21 @@ class SyncService:
|
||||
ctime=file_metadata.created_at.timestamp(),
|
||||
)
|
||||
|
||||
# Trigger: markdown file has no frontmatter and frontmatter enforcement is enabled
|
||||
# Why: watch/sync consumers rely on normalized metadata and stable permalinks
|
||||
# Outcome: file is updated in-place with derived title/type/permalink metadata
|
||||
if not file_contains_frontmatter and self.app_config.ensure_frontmatter_on_sync:
|
||||
permalink = await self.entity_service.resolve_permalink(
|
||||
path, markdown=entity_markdown, skip_conflict_check=True
|
||||
)
|
||||
frontmatter_updates = {
|
||||
"title": entity_markdown.frontmatter.title,
|
||||
"type": entity_markdown.frontmatter.type,
|
||||
"permalink": permalink,
|
||||
}
|
||||
await self.file_service.update_frontmatter(path, frontmatter_updates)
|
||||
entity_markdown.frontmatter.metadata.update(frontmatter_updates)
|
||||
|
||||
# if the file contains frontmatter, resolve a permalink (unless disabled)
|
||||
if file_contains_frontmatter and not self.app_config.disable_permalinks:
|
||||
# Resolve permalink - skip conflict checks during bulk sync for performance
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Union, runtime_checkable, List
|
||||
from typing import Protocol, Union, runtime_checkable, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from unidecode import unidecode
|
||||
@@ -202,6 +202,49 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
|
||||
return return_val
|
||||
|
||||
|
||||
def normalize_project_reference(identifier: str) -> str:
|
||||
"""Normalize project-prefixed references.
|
||||
|
||||
Converts project namespace syntax ("project::note") to path syntax ("project/note").
|
||||
Leaves non-namespaced identifiers unchanged.
|
||||
"""
|
||||
if "::" not in identifier:
|
||||
return identifier
|
||||
|
||||
project, remainder = identifier.split("::", 1)
|
||||
remainder = remainder.lstrip("/")
|
||||
return f"{project}/{remainder}"
|
||||
|
||||
|
||||
def build_canonical_permalink(
|
||||
project_permalink: Optional[str],
|
||||
file_path: Union[Path, str, PathLike],
|
||||
include_project: bool = True,
|
||||
) -> str:
|
||||
"""Build a canonical permalink, optionally prefixed with project slug.
|
||||
|
||||
Args:
|
||||
project_permalink: URL-friendly project identifier (slug). If None, no prefix is added.
|
||||
file_path: Original file path or permalink-like string.
|
||||
include_project: When True, prefix with project slug.
|
||||
|
||||
Returns:
|
||||
Canonical permalink string.
|
||||
"""
|
||||
normalized_path = generate_permalink(file_path)
|
||||
|
||||
if not include_project or not project_permalink:
|
||||
return normalized_path
|
||||
|
||||
normalized_project = generate_permalink(project_permalink)
|
||||
if normalized_path == normalized_project or normalized_path.startswith(
|
||||
f"{normalized_project}/"
|
||||
):
|
||||
return normalized_path
|
||||
|
||||
return f"{normalized_project}/{normalized_path}"
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
log_to_file: bool = False,
|
||||
@@ -445,17 +488,18 @@ def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datet
|
||||
|
||||
Args:
|
||||
dt: The datetime to ensure is timezone-aware
|
||||
cloud_mode: Optional explicit cloud_mode setting. If None, loads from config.
|
||||
cloud_mode: Optional explicit cloud_mode setting. If None, inferred from
|
||||
configured database backend (Postgres => UTC semantics).
|
||||
|
||||
Returns:
|
||||
A timezone-aware datetime
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# Determine cloud_mode: use explicit parameter if provided, otherwise load from config
|
||||
# Determine cloud_mode: use explicit parameter if provided, otherwise infer from config.
|
||||
if cloud_mode is None:
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.config import ConfigManager, DatabaseBackend
|
||||
|
||||
cloud_mode = ConfigManager().config.cloud_mode_enabled
|
||||
cloud_mode = ConfigManager().config.database_backend == DatabaseBackend.POSTGRES
|
||||
|
||||
if cloud_mode:
|
||||
# Cloud/PostgreSQL mode: naive datetimes from asyncpg are already UTC
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Integration tests for `basic-memory tool edit-note`."""
|
||||
|
||||
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, project: str | None = None) -> dict:
|
||||
args = [
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
title,
|
||||
"--folder",
|
||||
folder,
|
||||
"--content",
|
||||
content,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
if project:
|
||||
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:
|
||||
args = ["tool", "read-note", identifier, "--format", "json"]
|
||||
if project:
|
||||
args.extend(["--project", project])
|
||||
|
||||
result = runner.invoke(cli_app, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_edit_note_append_success(app, app_config, test_project, config_manager):
|
||||
"""append operation adds content to the end of the note."""
|
||||
note = _write_note(
|
||||
"Edit Append Note",
|
||||
"edit-tests",
|
||||
"# Append\n\nBASE_APPEND_MARKER",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"append",
|
||||
"--content",
|
||||
"\nAPPENDED_MARKER",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = _read_note(note["permalink"])
|
||||
assert updated["content"].index("APPENDED_MARKER") > updated["content"].index(
|
||||
"BASE_APPEND_MARKER"
|
||||
)
|
||||
|
||||
|
||||
def test_edit_note_prepend_success(app, app_config, test_project, config_manager):
|
||||
"""prepend operation inserts content before existing body content."""
|
||||
note = _write_note(
|
||||
"Edit Prepend Note",
|
||||
"edit-tests",
|
||||
"# Prepend\n\nBASE_PREPEND_MARKER",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"prepend",
|
||||
"--content",
|
||||
"PREPENDED_MARKER\n",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = _read_note(note["permalink"])
|
||||
assert updated["content"].index("PREPENDED_MARKER") < updated["content"].index(
|
||||
"BASE_PREPEND_MARKER"
|
||||
)
|
||||
|
||||
|
||||
def test_edit_note_find_replace_success_with_expected_count(
|
||||
app, app_config, test_project, config_manager
|
||||
):
|
||||
"""find_replace succeeds when expected replacement count matches actual count."""
|
||||
note = _write_note(
|
||||
"Edit Replace Note",
|
||||
"edit-tests",
|
||||
"# Replace\n\nFIND_ME_MARKER and FIND_ME_MARKER",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"find_replace",
|
||||
"--content",
|
||||
"REPLACED_MARKER",
|
||||
"--find-text",
|
||||
"FIND_ME_MARKER",
|
||||
"--expected-replacements",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = _read_note(note["permalink"])
|
||||
assert "FIND_ME_MARKER" not in updated["content"]
|
||||
assert updated["content"].count("REPLACED_MARKER") == 2
|
||||
|
||||
|
||||
def test_edit_note_find_replace_fails_without_find_text(
|
||||
app, app_config, test_project, config_manager
|
||||
):
|
||||
"""find_replace requires --find-text."""
|
||||
note = _write_note(
|
||||
"Edit Missing Find Note",
|
||||
"edit-tests",
|
||||
"# Missing Find\n\nOriginal",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"find_replace",
|
||||
"--content",
|
||||
"Replacement",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "find_text parameter is required for find_replace operation" in result.output
|
||||
|
||||
|
||||
def test_edit_note_replace_section_success(app, app_config, test_project, config_manager):
|
||||
"""replace_section updates exactly the targeted section body."""
|
||||
note = _write_note(
|
||||
"Edit Section Note",
|
||||
"edit-tests",
|
||||
"# Header\n\n## Keep\nKeep body\n\n## Target Section\nOld section body\n\n## After\nAfter body",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"replace_section",
|
||||
"--content",
|
||||
"New section body",
|
||||
"--section",
|
||||
"## Target Section",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = _read_note(note["permalink"])
|
||||
assert "New section body" in updated["content"]
|
||||
assert "Old section body" not in updated["content"]
|
||||
assert "## After" in updated["content"]
|
||||
|
||||
|
||||
def test_edit_note_replace_section_fails_without_section(
|
||||
app, app_config, test_project, config_manager
|
||||
):
|
||||
"""replace_section requires --section."""
|
||||
note = _write_note(
|
||||
"Edit Missing Section Note",
|
||||
"edit-tests",
|
||||
"# Missing Section\n\nBody",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"replace_section",
|
||||
"--content",
|
||||
"Replacement body",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "section parameter is required for replace_section operation" in result.output
|
||||
|
||||
|
||||
def test_edit_note_json_format_contract(app, app_config, test_project, config_manager):
|
||||
"""JSON format returns only metadata keys required by contract."""
|
||||
note = _write_note(
|
||||
"Edit JSON Note",
|
||||
"edit-tests",
|
||||
"# JSON\n\nBody",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"append",
|
||||
"--content",
|
||||
"\nJSON_MARKER",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.stdout)
|
||||
assert set(data.keys()) == {"title", "permalink", "file_path", "operation", "checksum"}
|
||||
assert data["operation"] == "append"
|
||||
assert data["title"] == "Edit JSON Note"
|
||||
|
||||
|
||||
def test_edit_note_text_backend_failure_returns_nonzero(
|
||||
app, app_config, test_project, config_manager
|
||||
):
|
||||
"""Text mode should return non-zero when backend edit operation fails."""
|
||||
note = _write_note(
|
||||
"Edit Backend Failure Note",
|
||||
"edit-tests",
|
||||
"# Failure\n\nGamma",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"find_replace",
|
||||
"--find-text",
|
||||
"Gamma",
|
||||
"--content",
|
||||
"Delta",
|
||||
"--expected-replacements",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "# Edit Failed - Wrong Replacement Count" in result.output
|
||||
assert "Expected 2 occurrences of 'Gamma' but found 1" in result.output
|
||||
|
||||
|
||||
def test_edit_note_project_and_routing_flag_parity(app, app_config, test_project, config_manager):
|
||||
"""edit-note supports --project/--local and validates --local/--cloud conflict."""
|
||||
note = _write_note(
|
||||
"Edit Project Flag Note",
|
||||
"edit-tests",
|
||||
"# Project Flag\n\nPROJECT_FLAG_MARKER",
|
||||
project=test_project.name,
|
||||
)
|
||||
|
||||
success = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"append",
|
||||
"--content",
|
||||
"\nPROJECT_UPDATE_MARKER",
|
||||
"--project",
|
||||
test_project.name,
|
||||
"--local",
|
||||
],
|
||||
)
|
||||
assert success.exit_code == 0, success.output
|
||||
assert "No such option" not in success.output
|
||||
|
||||
updated = _read_note(note["permalink"], project=test_project.name)
|
||||
assert "PROJECT_UPDATE_MARKER" in updated["content"]
|
||||
|
||||
conflict = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
note["permalink"],
|
||||
"--operation",
|
||||
"append",
|
||||
"--content",
|
||||
"ignored",
|
||||
"--local",
|
||||
"--cloud",
|
||||
],
|
||||
)
|
||||
assert conflict.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in conflict.output
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Failure-path integration tests for CLI tool --format json output.
|
||||
|
||||
Verifies that error conditions return proper exit codes and that
|
||||
error messages go to stderr, not stdout (which would break JSON parsing).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_read_note_not_found_json(app, app_config, test_project, config_manager):
|
||||
"""read-note with non-existent identifier returns error exit code."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "nonexistent-note-that-does-not-exist", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0, "Should fail for non-existent note"
|
||||
# stdout should NOT contain valid JSON with data (it's an error)
|
||||
# The error message should be informative
|
||||
output = result.stdout + (result.stderr if hasattr(result, "stderr") and result.stderr else "")
|
||||
assert (
|
||||
"error" in output.lower()
|
||||
or "not found" in output.lower()
|
||||
or "could not find" in output.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_write_note_missing_content_json(app, app_config, test_project, config_manager):
|
||||
"""write-note without content or stdin returns error exit code."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"No Content Note",
|
||||
"--folder",
|
||||
"test",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
input="", # Empty stdin
|
||||
)
|
||||
|
||||
# Should fail — no content provided
|
||||
assert result.exit_code != 0, "Should fail when no content is provided"
|
||||
|
||||
|
||||
def test_write_note_json_then_read_json_roundtrip(app, app_config, test_project, config_manager):
|
||||
"""write-note JSON output can be used to read-note by permalink."""
|
||||
# Write a note
|
||||
write_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Roundtrip Test",
|
||||
"--folder",
|
||||
"test-roundtrip",
|
||||
"--content",
|
||||
"# Roundtrip Test\n\nContent for roundtrip.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert write_result.exit_code == 0
|
||||
write_data = json.loads(write_result.stdout)
|
||||
assert "permalink" in write_data
|
||||
|
||||
# Read it back using the permalink from the write response
|
||||
read_result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", write_data["permalink"], "--format", "json"],
|
||||
)
|
||||
assert read_result.exit_code == 0
|
||||
read_data = json.loads(read_result.stdout)
|
||||
assert read_data["title"] == "Roundtrip Test"
|
||||
assert read_data["permalink"] == write_data["permalink"]
|
||||
|
||||
|
||||
def test_recent_activity_empty_project_json(
|
||||
app, app_config, test_project, config_manager, monkeypatch
|
||||
):
|
||||
"""recent-activity on empty project returns valid empty JSON list."""
|
||||
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--format", "json"],
|
||||
)
|
||||
|
||||
# Should succeed even if empty
|
||||
if result.exit_code == 0:
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
@@ -78,12 +78,98 @@ def test_read_note_json_format(app, app_config, test_project, config_manager):
|
||||
assert data["permalink"] == permalink
|
||||
assert "content" in data
|
||||
assert "file_path" in data
|
||||
assert "frontmatter" in data
|
||||
assert isinstance(data["frontmatter"], dict)
|
||||
|
||||
|
||||
def test_read_note_json_strip_frontmatter_permalink(app, app_config, test_project, config_manager):
|
||||
"""read-note strips frontmatter in JSON mode for permalink lookup."""
|
||||
write_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Read Strip Permalink Note",
|
||||
"--folder",
|
||||
"test-notes",
|
||||
"--content",
|
||||
"# Read Strip Permalink Note\n\nPermalink lookup content.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert write_result.exit_code == 0
|
||||
write_data = json.loads(write_result.stdout)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"read-note",
|
||||
write_data["permalink"],
|
||||
"--format",
|
||||
"json",
|
||||
"--strip-frontmatter",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data["title"] == "Read Strip Permalink Note"
|
||||
assert data["permalink"] == write_data["permalink"]
|
||||
assert not data["content"].startswith("---")
|
||||
assert "# Read Strip Permalink Note" in data["content"]
|
||||
assert isinstance(data["frontmatter"], dict)
|
||||
assert data["frontmatter"].get("title") == "Read Strip Permalink Note"
|
||||
|
||||
|
||||
def test_read_note_json_strip_frontmatter_title(app, app_config, test_project, config_manager):
|
||||
"""read-note strips frontmatter in JSON mode for title-based lookup."""
|
||||
write_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Read Strip Title Note",
|
||||
"--folder",
|
||||
"test-notes",
|
||||
"--content",
|
||||
"# Read Strip Title Note\n\nTitle lookup content.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert write_result.exit_code == 0
|
||||
write_data = json.loads(write_result.stdout)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"read-note",
|
||||
"Read Strip Title Note",
|
||||
"--format",
|
||||
"json",
|
||||
"--strip-frontmatter",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data["title"] == "Read Strip Title Note"
|
||||
assert data["permalink"] == write_data["permalink"]
|
||||
assert not data["content"].startswith("---")
|
||||
assert "# Read Strip Title Note" in data["content"]
|
||||
assert isinstance(data["frontmatter"], dict)
|
||||
assert data["frontmatter"].get("title") == "Read Strip Title Note"
|
||||
|
||||
|
||||
def test_recent_activity_json_format(app, app_config, test_project, config_manager, monkeypatch):
|
||||
"""Test recent-activity --format json returns valid JSON list."""
|
||||
# _recent_activity_json uses resolve_project_parameter which requires either
|
||||
# default_project_mode=True or BASIC_MEMORY_MCP_PROJECT to resolve a project
|
||||
# default_project set or BASIC_MEMORY_MCP_PROJECT to resolve a project
|
||||
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
|
||||
|
||||
# Write a note to ensure there's recent activity
|
||||
|
||||
@@ -7,11 +7,13 @@ from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
WIDE_TERMINAL_ENV = {"COLUMNS": "240", "LINES": "60"}
|
||||
|
||||
|
||||
def test_project_list(app, app_config, test_project, config_manager):
|
||||
"""Test 'bm project list' command shows projects."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
@@ -77,7 +79,7 @@ def test_project_add_and_remove(app, app_config, config_manager):
|
||||
)
|
||||
|
||||
# Verify it shows up in list
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
|
||||
assert result.exit_code == 0
|
||||
assert "new-project" in result.stdout
|
||||
|
||||
@@ -114,7 +116,7 @@ def test_project_set_default(app, app_config, config_manager):
|
||||
assert "default" in result.stdout.lower()
|
||||
|
||||
# Verify in list
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
|
||||
assert result.exit_code == 0
|
||||
# The new project should have the [X] marker now
|
||||
lines = result.stdout.split("\n")
|
||||
@@ -136,30 +138,37 @@ def test_remove_main_project(app, app_config, config_manager):
|
||||
new_default_path = Path(new_default_dir)
|
||||
|
||||
# Ensure main exists
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
if "main" not in result.stdout:
|
||||
result = runner.invoke(cli_app, ["project", "add", "main", str(main_path)])
|
||||
# Trigger: this test must work on Windows runners where output may contain "runneradmin".
|
||||
# Why: substring checks against command output can mistake path text for project names.
|
||||
# Outcome: use config state for setup decisions, then validate behavior via CLI invocation.
|
||||
if "main" not in config_manager.config.projects:
|
||||
result = runner.invoke(cli_app, ["project", "add", "main", str(main_path), "--local"])
|
||||
print(result.stdout)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm main is present
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
assert "main" in result.stdout
|
||||
assert "main" in config_manager.config.projects
|
||||
|
||||
# Add a second project
|
||||
result = runner.invoke(cli_app, ["project", "add", "new_default", str(new_default_path)])
|
||||
result = runner.invoke(
|
||||
cli_app, ["project", "add", "new_default", str(new_default_path), "--local"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Set new_default as default (if needed)
|
||||
result = runner.invoke(cli_app, ["project", "default", "new_default"])
|
||||
result = runner.invoke(cli_app, ["project", "default", "new_default", "--local"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Remove main
|
||||
result = runner.invoke(cli_app, ["project", "remove", "main"])
|
||||
result = runner.invoke(cli_app, ["project", "remove", "main", "--local"])
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"STDERR: {result.stderr}")
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm only new_default exists and main does not
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list", "--local"], env=WIDE_TERMINAL_ENV)
|
||||
assert result.exit_code == 0
|
||||
assert "main" not in result.stdout
|
||||
assert "new_default" in result.stdout
|
||||
config_after_list = config_manager.load_config()
|
||||
assert "main" not in config_after_list.projects
|
||||
assert "new_default" in config_after_list.projects
|
||||
|
||||
@@ -40,6 +40,15 @@ class TestRoutingFlagsValidation:
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_project_ls_both_flags_error(self):
|
||||
"""Using both --local and --cloud on project ls should produce an error."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["project", "ls", "--name", "test", "--local", "--cloud"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_tool_search_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"])
|
||||
@@ -60,6 +69,25 @@ class TestRoutingFlagsValidation:
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_tool_edit_note_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"edit-note",
|
||||
"test",
|
||||
"--operation",
|
||||
"append",
|
||||
"--content",
|
||||
"test",
|
||||
"--local",
|
||||
"--cloud",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
|
||||
class TestMcpCommandForcesLocal:
|
||||
"""Tests that the MCP command forces local routing."""
|
||||
@@ -99,6 +127,7 @@ class TestToolCommandsAcceptFlags:
|
||||
("search-notes", ["test query"]),
|
||||
("recent-activity", []),
|
||||
("read-note", ["test"]),
|
||||
("edit-note", ["test", "--operation", "append", "--content", "test"]),
|
||||
("build-context", ["memory://test"]),
|
||||
("continue-conversation", []),
|
||||
],
|
||||
@@ -116,6 +145,7 @@ class TestToolCommandsAcceptFlags:
|
||||
("search-notes", ["test query"]),
|
||||
("recent-activity", []),
|
||||
("read-note", ["test"]),
|
||||
("edit-note", ["test", "--operation", "append", "--content", "test"]),
|
||||
("build-context", ["memory://test"]),
|
||||
("continue-conversation", []),
|
||||
],
|
||||
@@ -166,6 +196,16 @@ class TestProjectCommandsAcceptFlags:
|
||||
result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_ls_accepts_local_flag(self, app_config):
|
||||
"""project ls should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_ls_accepts_cloud_flag(self, app_config):
|
||||
"""project ls should accept --cloud flag."""
|
||||
result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--cloud"])
|
||||
assert "No such option: --cloud" not in result.output
|
||||
|
||||
|
||||
class TestStatusCommandAcceptsFlags:
|
||||
"""Tests that status command accepts routing flags."""
|
||||
|
||||
@@ -107,7 +107,8 @@ def postgres_container(db_backend):
|
||||
yield None
|
||||
return
|
||||
|
||||
with PostgresContainer("postgres:16-alpine") as postgres:
|
||||
# Use pgvector image so CREATE EXTENSION vector succeeds in search repository
|
||||
with PostgresContainer("pgvector/pgvector:pg16") as postgres:
|
||||
yield postgres
|
||||
|
||||
|
||||
@@ -243,9 +244,7 @@ def app_config(
|
||||
env="test",
|
||||
projects=projects,
|
||||
default_project="test-project",
|
||||
default_project_mode=False, # Explicit False for test isolation - tests pass project explicitly
|
||||
update_permalinks_on_move=True,
|
||||
cloud_mode=False, # Explicitly disable cloud mode
|
||||
sync_changes=False, # Disable file sync in tests - prevents lifespan from starting blocking task
|
||||
database_backend=database_backend,
|
||||
database_url=database_url,
|
||||
@@ -292,10 +291,16 @@ def app(app_config, project_config, engine_factory, test_project, config_manager
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
app = fastapi_app
|
||||
previous_overrides = dict(app.dependency_overrides)
|
||||
app.dependency_overrides[get_project_config] = lambda: project_config
|
||||
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
|
||||
app.dependency_overrides[get_app_config] = lambda: app_config
|
||||
return app
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
# Restore overrides so one test's injected dependencies don't leak into
|
||||
# subsequent tests that use the same global FastAPI app instance.
|
||||
app.dependency_overrides = previous_overrides
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
||||
@@ -113,7 +113,7 @@ async def test_build_context_nonexistent_urls_return_empty_results(mcp_server, a
|
||||
assert len(result.content) == 1
|
||||
response = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
assert '"results":[]' in response # Empty results
|
||||
assert '"total_results":0' in response # Zero count
|
||||
assert '"primary_count":0' in response # Zero count
|
||||
assert '"metadata"' in response # But should have metadata
|
||||
|
||||
|
||||
@@ -183,6 +183,6 @@ async def test_build_context_pattern_matching_works(mcp_server, app, test_projec
|
||||
response = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should find the pattern matches but not the other note
|
||||
assert '"total_results":2' in response or '"primary_count":2' in response
|
||||
assert '"primary_count":2' in response
|
||||
assert "Pattern Test" in response
|
||||
assert "Other Note" not in response
|
||||
|
||||
@@ -90,10 +90,9 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
|
||||
assert "title" in first_result
|
||||
assert "url" in first_result
|
||||
|
||||
# Verify correct content found
|
||||
# Verify correct content found — target note must be present
|
||||
titles = [r["title"] for r in results_json["results"]]
|
||||
assert "Machine Learning Fundamentals" in titles
|
||||
assert "Data Visualization Guide" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -156,8 +155,9 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
|
||||
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
titles = [r["title"] for r in results_json["results"]]
|
||||
# Python note must appear; JS note may also appear since FTS
|
||||
# tokenizes broadly on shared terms like "frameworks"
|
||||
assert "Python Web Frameworks" in titles
|
||||
assert "JavaScript Frameworks" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
Integration tests for default project mode functionality.
|
||||
Integration tests for default project resolution.
|
||||
|
||||
Tests the default_project_mode configuration that allows tools to automatically
|
||||
Tests the default_project configuration that allows tools to automatically
|
||||
use the default_project when no project parameter is specified, covering
|
||||
parameter resolution hierarchy and mode-specific behavior.
|
||||
parameter resolution hierarchy and fallback behavior.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,19 +17,16 @@ from basic_memory.config import ConfigManager, BasicMemoryConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_project_mode_enabled_write_note(mcp_server, app, test_project):
|
||||
"""Test that write_note uses default project when default_project_mode=true and no project specified."""
|
||||
async def test_default_project_write_note(mcp_server, app, test_project):
|
||||
"""Test that write_note uses default project when no project specified."""
|
||||
|
||||
# Mock config with default_project_mode enabled
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=True,
|
||||
projects={test_project.name: test_project.path},
|
||||
)
|
||||
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# Call write_note without project parameter
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
@@ -43,7 +40,6 @@ async def test_default_project_mode_enabled_write_note(mcp_server, app, test_pro
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should use the default project
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: test/Default Mode Test.md" in response_text
|
||||
@@ -51,12 +47,11 @@ async def test_default_project_mode_enabled_write_note(mcp_server, app, test_pro
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_project_mode_explicit_override(
|
||||
async def test_explicit_project_overrides_default(
|
||||
mcp_server, app, test_project, config_home, engine_factory
|
||||
):
|
||||
"""Test that explicit project parameter overrides default_project_mode."""
|
||||
"""Test that explicit project parameter overrides default_project."""
|
||||
|
||||
# Create a second project for testing override
|
||||
engine, session_maker = engine_factory
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
|
||||
@@ -72,16 +67,13 @@ async def test_default_project_mode_explicit_override(
|
||||
}
|
||||
)
|
||||
|
||||
# Mock config with default_project_mode enabled pointing to test_project
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=True,
|
||||
projects={test_project.name: test_project.path, other_project.name: other_project.path},
|
||||
)
|
||||
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# Call write_note with explicit project parameter (should override default)
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
@@ -95,26 +87,22 @@ async def test_default_project_mode_explicit_override(
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should use the explicitly specified project, not default
|
||||
assert f"project: {other_project.name}" in response_text
|
||||
assert "# Created note" in response_text
|
||||
assert f"[Session: Using project '{other_project.name}']" in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_project_mode_disabled_requires_project(mcp_server, app, test_project):
|
||||
"""Test that tools require project parameter when default_project_mode=false."""
|
||||
async def test_no_default_project_requires_project(mcp_server, app, test_project):
|
||||
"""Test that tools require project parameter when no default_project is configured."""
|
||||
|
||||
# Mock config with default_project_mode disabled
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=False, # Disabled
|
||||
default_project=None, # No default
|
||||
projects={test_project.name: test_project.path},
|
||||
)
|
||||
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# Call write_note without project parameter - should fail
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
@@ -125,7 +113,6 @@ async def test_default_project_mode_disabled_requires_project(mcp_server, app, t
|
||||
},
|
||||
)
|
||||
|
||||
# Should get an error about missing project
|
||||
error_message = str(exc_info.value)
|
||||
assert (
|
||||
"No project specified" in error_message
|
||||
@@ -134,12 +121,11 @@ async def test_default_project_mode_disabled_requires_project(mcp_server, app, t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_constraint_overrides_default_project_mode(
|
||||
async def test_cli_constraint_overrides_default_project(
|
||||
mcp_server, app, test_project, config_home, engine_factory
|
||||
):
|
||||
"""Test that CLI --project constraint overrides default_project_mode."""
|
||||
"""Test that CLI --project constraint overrides default_project."""
|
||||
|
||||
# Create a different project for CLI constraint
|
||||
engine, session_maker = engine_factory
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
|
||||
@@ -155,20 +141,16 @@ async def test_cli_constraint_overrides_default_project_mode(
|
||||
}
|
||||
)
|
||||
|
||||
# Set up CLI project constraint (highest priority)
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = other_project.name
|
||||
|
||||
# Mock config with default_project_mode enabled pointing to test_project
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=True,
|
||||
projects={test_project.name: test_project.path, other_project.name: other_project.path},
|
||||
)
|
||||
|
||||
try:
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# Call write_note without project parameter
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
@@ -181,31 +163,26 @@ async def test_cli_constraint_overrides_default_project_mode(
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should use CLI constrained project, not default project
|
||||
assert f"project: {other_project.name}" in response_text
|
||||
assert "# Created note" in response_text
|
||||
assert f"[Session: Using project '{other_project.name}']" in response_text
|
||||
|
||||
finally:
|
||||
# Clean up environment variable
|
||||
if "BASIC_MEMORY_MCP_PROJECT" in os.environ:
|
||||
del os.environ["BASIC_MEMORY_MCP_PROJECT"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_project_mode_read_note(mcp_server, app, test_project):
|
||||
"""Test that read_note works with default_project_mode."""
|
||||
async def test_default_project_read_note(mcp_server, app, test_project):
|
||||
"""Test that read_note works with default_project."""
|
||||
|
||||
# Mock config with default_project_mode enabled
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=True,
|
||||
projects={test_project.name: test_project.path},
|
||||
)
|
||||
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# First create a note
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
@@ -215,7 +192,6 @@ async def test_default_project_mode_read_note(mcp_server, app, test_project):
|
||||
},
|
||||
)
|
||||
|
||||
# Now read it back without specifying project
|
||||
result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
@@ -226,25 +202,21 @@ async def test_default_project_mode_read_note(mcp_server, app, test_project):
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should successfully read the note
|
||||
assert "# Read Test Note" in response_text
|
||||
assert "This note will be read back." in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_project_mode_edit_note(mcp_server, app, test_project):
|
||||
"""Test that edit_note works with default_project_mode."""
|
||||
async def test_default_project_edit_note(mcp_server, app, test_project):
|
||||
"""Test that edit_note works with default_project."""
|
||||
|
||||
# Mock config with default_project_mode enabled
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=test_project.name,
|
||||
default_project_mode=True,
|
||||
projects={test_project.name: test_project.path},
|
||||
)
|
||||
|
||||
with patch.object(ConfigManager, "config", mock_config):
|
||||
async with Client(mcp_server) as client:
|
||||
# First create a note
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
@@ -254,7 +226,6 @@ async def test_default_project_mode_edit_note(mcp_server, app, test_project):
|
||||
},
|
||||
)
|
||||
|
||||
# Now edit it without specifying project
|
||||
result = await client.call_tool(
|
||||
"edit_note",
|
||||
{
|
||||
@@ -267,7 +238,6 @@ async def test_default_project_mode_edit_note(mcp_server, app, test_project):
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# Should successfully edit the note
|
||||
assert "# Edited note" in response_text
|
||||
assert "operation: Added" in response_text
|
||||
|
||||
@@ -278,7 +248,6 @@ async def test_project_resolution_hierarchy(
|
||||
):
|
||||
"""Test the complete three-tier project resolution hierarchy."""
|
||||
|
||||
# Create projects for testing
|
||||
engine, session_maker = engine_factory
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
|
||||
@@ -304,10 +273,8 @@ async def test_project_resolution_hierarchy(
|
||||
}
|
||||
)
|
||||
|
||||
# Mock config with default_project_mode enabled
|
||||
mock_config = BasicMemoryConfig(
|
||||
default_project=default_project.name,
|
||||
default_project_mode=True,
|
||||
projects={
|
||||
default_project.name: Path(default_project.path).as_posix(),
|
||||
cli_project.name: Path(cli_project.path).as_posix(),
|
||||
|
||||
@@ -441,8 +441,8 @@ This note contains unique search terms:
|
||||
|
||||
assert len(search_after.content) > 0
|
||||
search_text = search_after.content[0].text
|
||||
assert "quantum mechanics" in search_text
|
||||
assert "research/quantum-ai-note.md" in search_text or "quantum-ai-note" in search_text
|
||||
# Search results include observations/relations — check the note is found by file path
|
||||
assert "quantum-ai-note" in search_text
|
||||
|
||||
# Verify search by new location works
|
||||
search_by_path = await client.call_tool(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user