mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88a5b07b89 | |||
| 59e8a937ee | |||
| c07465d904 | |||
| dfb89e841c |
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {}
|
||||
"enabledPlugins": {
|
||||
"basic-memory@basicmachines": true
|
||||
}
|
||||
}
|
||||
|
||||
+46
-200
@@ -1,37 +1,53 @@
|
||||
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:
|
||||
static-checks:
|
||||
name: Static Checks (Python 3.12)
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
test-sqlite:
|
||||
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [ "3.12", "3.13", "3.14" ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- name: Install just (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- 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
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -39,7 +55,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
@@ -49,104 +65,13 @@ jobs:
|
||||
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:
|
||||
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
|
||||
- name: Run tests (SQLite)
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install pytest pytest-cov
|
||||
just test-sqlite
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests (SQLite Unit)
|
||||
run: |
|
||||
just test-unit-sqlite
|
||||
|
||||
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: |
|
||||
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 (SQLite Integration)
|
||||
run: |
|
||||
just test-int-sqlite
|
||||
|
||||
test-postgres-unit:
|
||||
name: Test Postgres Unit (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
needs: [static-checks]
|
||||
test-postgres:
|
||||
name: Test Postgres (Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -170,7 +95,9 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -178,99 +105,15 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run tests (Postgres Unit)
|
||||
- name: Run tests (Postgres via testcontainers)
|
||||
run: |
|
||||
just test-unit-postgres
|
||||
|
||||
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:
|
||||
- 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,semantic]"
|
||||
|
||||
- name: Run tests (Semantic)
|
||||
run: |
|
||||
just test-semantic
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
|
||||
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:
|
||||
@@ -288,7 +131,9 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -296,10 +141,11 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev,semantic]"
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run combined coverage (SQLite + Postgres)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just coverage
|
||||
|
||||
- name: Add coverage report to job summary
|
||||
|
||||
@@ -57,4 +57,3 @@ claude-output
|
||||
.mcp.json
|
||||
.mcpregistry_*
|
||||
/.testmondata
|
||||
.benchmarks/
|
||||
|
||||
@@ -189,46 +189,27 @@ Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Reposito
|
||||
|
||||
### Async Client Pattern (Important!)
|
||||
|
||||
**MCP tools use `get_project_client()` for per-project routing:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
@mcp.tool()
|
||||
async def my_tool(project: str | None = None, context: Context | None = None):
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local ASGI or cloud HTTP)
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
```
|
||||
|
||||
**CLI commands and non-project-scoped code use `get_client()` directly:**
|
||||
**All MCP tools and CLI commands use the context manager pattern for HTTP clients:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
async def my_cli_command():
|
||||
async def my_mcp_tool():
|
||||
async with get_client() as client:
|
||||
# Use client for API calls
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
|
||||
# Per-project routing (when project name is known):
|
||||
async with get_client(project_name="research") as client:
|
||||
...
|
||||
```
|
||||
|
||||
**Do NOT use:**
|
||||
- ❌ `from basic_memory.mcp.async_client import client` (deprecated module-level client)
|
||||
- ❌ Manual auth header management
|
||||
- ❌ `inject_auth_header()` (deleted)
|
||||
- ❌ Separate `get_client()` + `get_active_project()` in MCP tools (use `get_project_client()` instead)
|
||||
|
||||
**Key principles:**
|
||||
- Auth happens at client creation, not per-request
|
||||
- Proper resource management via context managers
|
||||
- Per-project routing: each project can be LOCAL or CLOUD independently
|
||||
- Cloud projects use API key (`cloud_api_key` in config) as Bearer token
|
||||
- Routing priority: factory injection > force-local > per-project cloud > global cloud > local ASGI
|
||||
- Supports three modes: Local (ASGI), CLI cloud (HTTP + auth), Cloud app (factory injection)
|
||||
- Factory pattern enables dependency injection for cloud consolidation
|
||||
|
||||
**For cloud app integration:**
|
||||
@@ -269,19 +250,15 @@ See SPEC-16 for full context manager refactor details.
|
||||
- List projects: `basic-memory project list`
|
||||
- Add project: `basic-memory project add "name" ~/path`
|
||||
- Project info: `basic-memory project info`
|
||||
- Set cloud mode: `basic-memory project set-cloud "name"`
|
||||
- Set local mode: `basic-memory project set-local "name"`
|
||||
- One-way sync (local -> cloud): `basic-memory project sync`
|
||||
- Bidirectional sync: `basic-memory project bisync`
|
||||
- Integrity check: `basic-memory project check`
|
||||
|
||||
**Cloud Commands (requires subscription):**
|
||||
- Authenticate (global): `basic-memory cloud login`
|
||||
- Logout (global): `basic-memory cloud logout`
|
||||
- Authenticate: `basic-memory cloud login`
|
||||
- Logout: `basic-memory cloud logout`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
- Save API key: `basic-memory cloud set-key bmc_...`
|
||||
- Create API key: `basic-memory cloud create-key "name"`
|
||||
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
|
||||
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
|
||||
|
||||
@@ -352,22 +329,9 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
|
||||
- Background relation resolution (non-blocking startup)
|
||||
- API performance optimizations (SPEC-11)
|
||||
|
||||
**Per-Project Cloud Routing:**
|
||||
**CLI Routing Flags:**
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local, using an API key:
|
||||
|
||||
```bash
|
||||
# Save API key and set project to cloud mode
|
||||
basic-memory cloud set-key bmc_abc123...
|
||||
basic-memory project set-cloud research # route through cloud
|
||||
basic-memory project set-local research # revert to local
|
||||
```
|
||||
|
||||
MCP tools use `get_project_client()` which automatically routes based on the project's mode. Cloud projects use the `cloud_api_key` from config as Bearer token.
|
||||
|
||||
**CLI Routing Flags (Global Cloud Mode):**
|
||||
|
||||
When global cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
|
||||
```bash
|
||||
# Force local routing (ignore cloud mode)
|
||||
@@ -384,7 +348,6 @@ Key behaviors:
|
||||
- This allows simultaneous use of local Claude Desktop and cloud-based clients
|
||||
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
|
||||
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
|
||||
- Per-project cloud routing via API key works independently of global cloud mode
|
||||
|
||||
## AI-Human Collaborative Development
|
||||
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
# 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
|
||||
|
||||
- Use global `--header` flag for Tigris consistency on all rclone transactions
|
||||
([`7fcf587`](https://github.com/basicmachines-co/basic-memory/commit/7fcf587))
|
||||
- `--header-download` / `--header-upload` only apply to GET/PUT requests, missing S3
|
||||
ListObjectsV2 calls that bisync issues first. Non-US users saw stale edge-cached metadata.
|
||||
- `--header` applies to ALL HTTP transactions (list, download, upload), fixing bisync for
|
||||
users outside the Tigris origin region.
|
||||
|
||||
## v0.18.2 (2026-02-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
-494
@@ -1,494 +0,0 @@
|
||||
# Note Format Reference
|
||||
|
||||
Every document in Basic Memory is a plain Markdown file. Files are the source of truth — changes to files automatically update the knowledge graph in the database. You maintain complete ownership, files work with git, and knowledge persists independently of any AI conversation.
|
||||
|
||||
## Document Structure
|
||||
|
||||
A note has three parts: YAML frontmatter, content (observations), and relations.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
type: note
|
||||
tags: [coffee, brewing]
|
||||
permalink: coffee-brewing-methods
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
|
||||
## Observations
|
||||
- [method] Pour over provides more flavor clarity than French press
|
||||
- [technique] Water temperature at 205°F extracts optimal compounds #brewing
|
||||
- [preference] Ethiopian beans work well with lighter roasts (personal experience)
|
||||
|
||||
## Relations
|
||||
- relates_to [[Coffee Bean Origins]]
|
||||
- requires [[Proper Grinding Technique]]
|
||||
- contrasts_with [[Tea Brewing Methods]]
|
||||
```
|
||||
|
||||
The `## Observations` and `## Relations` headings are conventional but not required — the parser detects observations and relations by their syntax patterns anywhere in the document.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML metadata between `---` fences at the top of the file.
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `title` | No | filename stem | Used for linking and references. Auto-set from filename if missing. |
|
||||
| `type` | No | `note` | Entity type. Used for schema resolution and filtering. |
|
||||
| `tags` | No | `[]` | List or comma-separated string. Used for organization and search. |
|
||||
| `permalink` | No | generated from title | Stable identifier. Persists even if the file moves. |
|
||||
| `schema` | No | none | Schema attachment — dict (inline), string (reference), or omitted (implicit). |
|
||||
|
||||
Custom fields are allowed. Any key not in the standard set is stored as `entity_metadata` and indexed for search and filtering.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
permalink: paul-graham
|
||||
status: active
|
||||
source: wikipedia
|
||||
---
|
||||
```
|
||||
|
||||
Here `status` and `source` are custom fields stored in `entity_metadata`.
|
||||
|
||||
### Frontmatter Value Handling
|
||||
|
||||
YAML automatically converts some values to native types. Basic Memory normalizes them:
|
||||
|
||||
- Date strings (`2025-10-24`) → kept as ISO format strings
|
||||
- Numbers (`1.0`) → converted to strings
|
||||
- Booleans (`true`) → converted to strings (`"True"`)
|
||||
- Lists and dicts → preserved, items normalized recursively
|
||||
|
||||
This prevents errors when downstream code expects string values.
|
||||
|
||||
## Observations
|
||||
|
||||
An observation is a categorized fact about the entity. Written as a Markdown list item.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- [category] content text #tag1 #tag2 (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `[category]` | Yes | Classification in square brackets. Any text except `[]()` chars. |
|
||||
| content | Yes | The fact or statement. |
|
||||
| `#tags` | No | Inline tags. Space-separated, each starting with `#`. |
|
||||
| `(context)` | No | Parenthesized text at end of line. Supporting details or source. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- [tech] Uses SQLite for storage #database
|
||||
- [design] Follows local-first architecture #architecture
|
||||
- [decision] Selected bcrypt for passwords #security (based on OWASP audit)
|
||||
- [name] Paul Graham
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
```
|
||||
|
||||
Array-like fields use repeated categories — multiple `[expertise]` observations above.
|
||||
|
||||
### What Is Not an Observation
|
||||
|
||||
The parser excludes these list item patterns:
|
||||
|
||||
| Pattern | Example | Reason |
|
||||
|---------|---------|--------|
|
||||
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
|
||||
| Markdown links | `- [text](url)` | URL link syntax |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
## Relations
|
||||
|
||||
Relations connect documents to form the knowledge graph. There are two kinds.
|
||||
|
||||
### Explicit Relations
|
||||
|
||||
Written as list items with a relation type and a `[[wiki link]]` target.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- relation_type [[Target Entity]] (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
|
||||
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- implements [[Search Design]]
|
||||
- depends_on [[Database Schema]]
|
||||
- works_at [[Y Combinator]] (co-founder)
|
||||
- [[Some Entity]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
Relations can link to entities that don't exist yet. Basic Memory resolves them when the target is created.
|
||||
|
||||
## Permalinks and memory:// URLs
|
||||
|
||||
Every document has a unique **permalink** — a stable identifier derived from its title. You can set one explicitly in frontmatter, or let the system generate it.
|
||||
|
||||
```yaml
|
||||
permalink: auth-approaches-2024
|
||||
```
|
||||
|
||||
Permalinks form the basis of `memory://` URLs:
|
||||
|
||||
```
|
||||
memory://auth-approaches-2024 # By permalink
|
||||
memory://Authentication Approaches # By title (auto-resolves)
|
||||
memory://project/auth-approaches # By path
|
||||
```
|
||||
|
||||
Pattern matching is supported:
|
||||
|
||||
```
|
||||
memory://auth* # Starts with "auth"
|
||||
memory://*/approaches # Ends with "approaches"
|
||||
memory://project/*/requirements # Nested wildcard
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
Schemas declare the expected structure of a note — which observation categories and relation types a well-formed note should have. They use Picoschema, a compact notation from Google's Dotprompt that fits naturally in YAML frontmatter.
|
||||
|
||||
### Picoschema Syntax
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
| Notation | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `field: type` | Required field | `name: string` |
|
||||
| `field?: type` | Optional field | `role?: string` |
|
||||
| `field(array): type` | Array of values | `expertise(array): string` |
|
||||
| `field?(enum): [vals]` | Enum with allowed values | `status?(enum): [active, inactive]` |
|
||||
| `field?(object):` | Nested object with sub-fields | `metadata?(object):` |
|
||||
| `, description` | Description after comma | `name: string, full name` |
|
||||
| `EntityName` | Capitalized type = entity reference | `works_at?: Organization` |
|
||||
|
||||
**Scalar types:** `string`, `integer`, `number`, `boolean`, `any`
|
||||
|
||||
Any type not in that set whose first letter is uppercase is treated as an entity reference (a relation target).
|
||||
|
||||
### Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against existing observation/relation syntax. Note authors don't learn new syntax.
|
||||
|
||||
| Schema Declaration | Maps To | Example in Note |
|
||||
|--------------------|---------|-----------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (repeated) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (repeated) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [vals]` | Observation `[field] value` where value is in the set | `- [status] active` |
|
||||
|
||||
Observations and relations not covered by the schema are valid — schemas describe a subset, not a straitjacket.
|
||||
|
||||
### Schema Attachment
|
||||
|
||||
Three ways to attach a schema to a note, resolved in priority order:
|
||||
|
||||
**1. Inline schema** — `schema` is a dict in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
**2. Explicit reference** — `schema` is a string naming a schema note:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject
|
||||
---
|
||||
```
|
||||
|
||||
or by permalink:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project
|
||||
---
|
||||
```
|
||||
|
||||
Use when the note's `type` differs from the schema it should validate against, or when multiple schema variants exist.
|
||||
|
||||
**3. Implicit by type** — no `schema` field, resolved by matching `type`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
---
|
||||
```
|
||||
|
||||
The system looks up a schema note where `entity: Person`. If found, it applies. If not, no validation occurs.
|
||||
|
||||
**4. No schema** — perfectly fine. Most notes don't need one.
|
||||
|
||||
### Schema Notes
|
||||
|
||||
A schema is itself a Basic Memory note with `type: schema`. It lives anywhere (though `schema/` is the conventional directory).
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | Yes | Must be `schema` |
|
||||
| `entity` | Yes | The entity type this schema describes (e.g., `Person`) |
|
||||
| `version` | No | Schema version number (default: `1`) |
|
||||
| `schema` | Yes | Picoschema dict defining the fields |
|
||||
| `settings.validation` | No | Validation mode (default: `warn`) |
|
||||
|
||||
Schema notes are regular notes — they show up in search, can have observations and relations, and participate in the knowledge graph.
|
||||
|
||||
### Validation Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
| `off` | No validation |
|
||||
|
||||
### Validation Output
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- **100% present** → required field
|
||||
- **25%+ present** → optional field
|
||||
- **Below 25%** → excluded from suggestion
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Simple Note (No Schema)
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Project Ideas
|
||||
type: note
|
||||
tags: [ideas, brainstorm]
|
||||
---
|
||||
|
||||
# Project Ideas
|
||||
|
||||
## Observations
|
||||
- [idea] Build a CLI tool for markdown linting #tooling
|
||||
- [idea] Create a recipe knowledge base #cooking
|
||||
- [priority] Focus on developer tools first (Q1 goal)
|
||||
|
||||
## Relations
|
||||
- inspired_by [[Developer Workflow Research]]
|
||||
- part_of [[Q1 Planning]]
|
||||
```
|
||||
|
||||
### Schema-Validated Note
|
||||
|
||||
Schema at `schema/Person.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
Note at `people/paul-graham.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
The `[fact]` observation and `authored` relation are not in the schema — they're valid, just unmatched. The schema only checks that `[name]` exists (required) and looks for optional fields like `[role]`, `[expertise]`, and `works_at`.
|
||||
|
||||
### Inline Schema Note
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 🚀 Basic Memory Cloud is Live!
|
||||
|
||||
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile.
|
||||
- **Cloud is optional.** The local-first open-source workflow continues as always.
|
||||
- **OSS discount:** use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
|
||||
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile - seamlessly synced across all your AI tools (Claude, ChatGPT, Gemini, Claude Code, and Codex)
|
||||
- **Early Supporter Pricing:** Early users get 25% off forever.
|
||||
The open source project continues as always. Cloud just makes it work everywhere.
|
||||
|
||||
[Sign up now →](https://basicmemory.com)
|
||||
|
||||
@@ -344,93 +344,52 @@ basic-memory sync --watch
|
||||
3. Cloud features (optional, requires subscription):
|
||||
|
||||
```bash
|
||||
# Authenticate with cloud (stores OAuth token locally)
|
||||
# Authenticate with cloud
|
||||
basic-memory cloud login
|
||||
|
||||
# (Optional) install/configure rclone for file sync commands
|
||||
basic-memory cloud setup
|
||||
# Bidirectional sync with cloud
|
||||
basic-memory cloud sync
|
||||
|
||||
# Check cloud auth + health
|
||||
basic-memory cloud status
|
||||
# Verify cloud integrity
|
||||
basic-memory cloud check
|
||||
|
||||
# Mount cloud storage
|
||||
basic-memory cloud mount
|
||||
```
|
||||
|
||||
**Per-Project Cloud Routing** (API key based):
|
||||
**Routing Flags** (for users with cloud subscriptions):
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local. This uses an API key for routed
|
||||
project calls:
|
||||
When cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
|
||||
|
||||
```bash
|
||||
# Save an API key (create one in the web app or via CLI)
|
||||
basic-memory cloud set-key bmc_abc123...
|
||||
# Or create one via CLI (requires OAuth login first)
|
||||
basic-memory cloud create-key "my-laptop"
|
||||
|
||||
# Set a project to route through cloud
|
||||
basic-memory project set-cloud research
|
||||
|
||||
# Revert a project to local mode
|
||||
basic-memory project set-local research
|
||||
|
||||
# List projects and route metadata
|
||||
basic-memory project list
|
||||
```
|
||||
|
||||
`basic-memory cloud login` / `basic-memory cloud logout` are authentication commands. They do not change default CLI
|
||||
routing behavior.
|
||||
|
||||
**Routing Flags**:
|
||||
|
||||
Use routing flags to disambiguate command targets:
|
||||
|
||||
```bash
|
||||
# Force local routing for this command
|
||||
# Force local routing (useful for local MCP server while cloud mode is enabled)
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
basic-memory project ls --name main --local
|
||||
|
||||
# Force cloud routing for this command
|
||||
# Force cloud routing (when cloud mode is disabled but you want cloud access)
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
basic-memory project ls --name main --cloud
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
|
||||
|
||||
4. In Claude Desktop, the LLM can now use these tools:
|
||||
|
||||
**Content Management:**
|
||||
```
|
||||
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
|
||||
write_note(title, content, folder, tags) - Create or update notes
|
||||
read_note(identifier, page, page_size) - 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, 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
|
||||
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
|
||||
```
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
```
|
||||
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
|
||||
build_context(url, depth, timeframe) - Navigate knowledge graph via memory:// URLs
|
||||
recent_activity(type, depth, timeframe) - Find recently updated information
|
||||
list_directory(dir_name, depth) - Browse directory contents with filtering
|
||||
```
|
||||
|
||||
@@ -443,21 +402,12 @@ search_by_metadata(filters, limit, offset, project) - Structured frontmatter sea
|
||||
|
||||
**Project Management:**
|
||||
```
|
||||
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
|
||||
list_memory_projects() - List all available projects
|
||||
create_memory_project(project_name, project_path) - 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
|
||||
release_notes() - Show latest release notes
|
||||
```
|
||||
|
||||
**Visualization:**
|
||||
```
|
||||
canvas(nodes, edges, title, folder) - Generate knowledge visualizations
|
||||
@@ -501,9 +451,7 @@ 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 |
|
||||
| `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_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) |
|
||||
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
|
||||
|
||||
### Examples
|
||||
|
||||
+12
-29
@@ -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 (local/test)
|
||||
2. Resolves runtime mode (cloud/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,7 +52,10 @@ class Container:
|
||||
def create(cls) -> "Container":
|
||||
"""Create container by reading ConfigManager."""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@property
|
||||
@@ -96,20 +99,17 @@ class RuntimeMode(Enum):
|
||||
return self == RuntimeMode.TEST
|
||||
```
|
||||
|
||||
Resolution follows this precedence in local app flows: **TEST > LOCAL**
|
||||
Resolution follows this precedence: **TEST > CLOUD > LOCAL**
|
||||
|
||||
```python
|
||||
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
|
||||
def resolve_runtime_mode(cloud_mode_enabled: bool, 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`,
|
||||
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
|
||||
|
||||
### Structure
|
||||
@@ -221,7 +221,9 @@ async def search_notes(
|
||||
tags: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
) -> SearchResponse:
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project)
|
||||
|
||||
# Import client inside function to avoid circular imports
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
@@ -236,25 +238,6 @@ async def search_notes(
|
||||
return await search_client.search(search_query.model_dump())
|
||||
```
|
||||
|
||||
### Per-Project Client Routing
|
||||
|
||||
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
|
||||
1. Resolves the project name from config (no network call)
|
||||
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
|
||||
3. Validates the project via the API
|
||||
4. Yields `(client, active_project)` tuple
|
||||
|
||||
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local or cloud)
|
||||
# active_project is validated via the API
|
||||
...
|
||||
```
|
||||
|
||||
## Sync Coordination
|
||||
|
||||
### SyncCoordinator
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Simplified Local/Cloud Routing
|
||||
|
||||
## Context
|
||||
|
||||
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.
|
||||
|
||||
This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients.
|
||||
|
||||
## Goals
|
||||
|
||||
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.
|
||||
|
||||
## Routing Contract
|
||||
|
||||
Routing is resolved in this order:
|
||||
|
||||
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
|
||||
|
||||
### Routing Environment Variables
|
||||
|
||||
- `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
|
||||
|
||||
When explicit routing is active, project mode does not override the selected route.
|
||||
|
||||
## Config Semantics
|
||||
|
||||
- `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`.
|
||||
|
||||
### Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
## Cloud Commands Are Auth-Only
|
||||
|
||||
`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state.
|
||||
|
||||
- `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
|
||||
|
||||
## 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.
|
||||
+61
-190
@@ -5,7 +5,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
|
||||
## Overview
|
||||
|
||||
The cloud CLI enables you to:
|
||||
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
|
||||
- **Toggle cloud mode** - All regular `bm` commands work with cloud when enabled
|
||||
- **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
|
||||
@@ -17,8 +17,6 @@ Before using Basic Memory Cloud, you need:
|
||||
|
||||
- **Active Subscription**: An active Basic Memory Cloud subscription is required to access cloud features
|
||||
- **Subscribe**: Visit [https://basicmemory.com/subscribe](https://basicmemory.com/subscribe) to sign up
|
||||
- **Optional**: Cloud is optional. Local-first open-source usage continues without cloud.
|
||||
- **OSS Discount**: Use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
|
||||
|
||||
If you attempt to log in without an active subscription, you'll receive a "Subscription Required" error with a link to subscribe.
|
||||
|
||||
@@ -40,7 +38,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 and is not routed to cloud
|
||||
3. **Local-only** - Project exists locally (when cloud mode is disabled)
|
||||
|
||||
**Example:**
|
||||
|
||||
@@ -50,9 +48,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 --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add temp --cloud # No local sync
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add temp # No local sync
|
||||
|
||||
# Now you can sync individually (after initial --resync):
|
||||
bm project bisync --name research
|
||||
@@ -68,9 +66,9 @@ bm project bisync --name work
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Authenticate Cloud Access
|
||||
### 1. Enable Cloud Mode
|
||||
|
||||
Authenticate with cloud:
|
||||
Authenticate and enable cloud mode:
|
||||
|
||||
```bash
|
||||
bm cloud login
|
||||
@@ -78,12 +76,11 @@ bm cloud login
|
||||
|
||||
**What this does:**
|
||||
1. Opens browser to Basic Memory Cloud authentication page
|
||||
2. Stores authentication tokens in `~/.basic-memory/basic-memory-cloud.json`
|
||||
3. Validates your subscription status
|
||||
4. Leaves routing behavior unchanged (auth only)
|
||||
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
|
||||
|
||||
**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.
|
||||
**Result:** All `bm project`, `bm tools` commands now work with cloud.
|
||||
|
||||
### 2. Set Up Sync
|
||||
|
||||
@@ -107,10 +104,10 @@ Create projects with optional local sync paths:
|
||||
|
||||
```bash
|
||||
# Create cloud project without local sync
|
||||
bm project add research --cloud
|
||||
bm project add research
|
||||
|
||||
# Create cloud project WITH local sync
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add research --local-path ~/Documents/research
|
||||
|
||||
# Or configure sync for existing project
|
||||
bm project sync-setup research ~/Documents/research
|
||||
@@ -120,7 +117,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 for that project (`cloud_sync_path`)
|
||||
2. Local path stored in config: `cloud_projects.research.local_path = "~/Documents/research"`
|
||||
3. Local directory created if it doesn't exist
|
||||
4. Bisync state directory created at `~/.basic-memory/bisync-state/research/`
|
||||
|
||||
@@ -182,8 +179,7 @@ bm cloud status
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `OAuth: token valid` (or missing/expired)
|
||||
- `API Key: configured` (or not set)
|
||||
- `Mode: Cloud (enabled)`
|
||||
- `Cloud instance is healthy`
|
||||
- Instructions for project sync commands
|
||||
|
||||
@@ -191,16 +187,16 @@ You should see:
|
||||
|
||||
### Understanding Project Commands
|
||||
|
||||
**Key concept:** Use regular `bm project` commands (not `bm cloud project`).
|
||||
**Key concept:** When cloud mode is enabled, use regular `bm project` commands (not `bm cloud project`).
|
||||
|
||||
```bash
|
||||
# Local route
|
||||
bm project list --local
|
||||
bm project add research ~/Documents/research
|
||||
# In cloud mode:
|
||||
bm project list # Lists cloud projects
|
||||
bm project add research # Creates cloud project
|
||||
|
||||
# Cloud route
|
||||
bm project list --cloud
|
||||
bm project add research --cloud
|
||||
# In local mode:
|
||||
bm project list # Lists local projects
|
||||
bm project add research ~/Documents/research # Creates local project
|
||||
```
|
||||
|
||||
### Creating Projects
|
||||
@@ -208,7 +204,7 @@ bm project add research --cloud
|
||||
**Use case 1: Cloud-only project (no local sync)**
|
||||
|
||||
```bash
|
||||
bm project add temp-notes --cloud
|
||||
bm project add temp-notes
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -221,7 +217,7 @@ bm project add temp-notes --cloud
|
||||
**Use case 2: Cloud project with local sync**
|
||||
|
||||
```bash
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add research --local-path ~/Documents/research
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -255,35 +251,11 @@ bm project list
|
||||
```
|
||||
|
||||
**What you see:**
|
||||
- Local projects always
|
||||
- Cloud projects when credentials are available
|
||||
- All projects in cloud (when cloud mode enabled)
|
||||
- Default project marked
|
||||
- Route-related metadata (for example, local/cloud presence and sync info)
|
||||
- Project paths shown
|
||||
|
||||
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.
|
||||
**Future:** Will show sync status (synced/not synced, last sync time).
|
||||
|
||||
## File Synchronization
|
||||
|
||||
@@ -391,28 +363,24 @@ bm project bisync --name research --dry-run
|
||||
|
||||
**Result:** Safe preview of sync operations.
|
||||
|
||||
### Advanced: List Project Files by Route
|
||||
### Advanced: List Remote Files
|
||||
|
||||
**Use case:** Inspect local or cloud project files explicitly.
|
||||
**Use case:** See what files exist on cloud without syncing.
|
||||
|
||||
```bash
|
||||
# List local project files (default target when no route flag is given)
|
||||
# List all files in project
|
||||
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 --cloud --path subfolder
|
||||
bm project ls --name research --path subfolder
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Resolves route from flags (or local default when no route is given)
|
||||
2. Lists files for the chosen project instance
|
||||
1. Connects to cloud via rclone
|
||||
2. Lists files in remote project path
|
||||
3. No files transferred
|
||||
|
||||
**Result:** See file listing for the target route.
|
||||
**Result:** See cloud file listing.
|
||||
|
||||
## Multiple Projects
|
||||
|
||||
@@ -422,9 +390,9 @@ bm project ls --name research --cloud --path subfolder
|
||||
|
||||
```bash
|
||||
# Setup multiple projects
|
||||
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
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add personal --local-path ~/personal
|
||||
|
||||
# Establish baselines
|
||||
bm project bisync --name research --resync
|
||||
@@ -449,12 +417,12 @@ bm project bisync --all # Coming soon
|
||||
|
||||
```bash
|
||||
# Projects with sync
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work
|
||||
|
||||
# Cloud-only projects
|
||||
bm project add archive --cloud
|
||||
bm project add temp-notes --cloud
|
||||
bm project add archive
|
||||
bm project add temp-notes
|
||||
|
||||
# Sync only the configured ones
|
||||
bm project bisync --name research
|
||||
@@ -465,101 +433,20 @@ bm project bisync --name work
|
||||
|
||||
**Result:** Fine-grained control over what syncs.
|
||||
|
||||
## Per-Project Cloud Routing (API Key)
|
||||
## Disable Cloud Mode
|
||||
|
||||
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
|
||||
|
||||
**Option A: Create a key in the web app, then save it locally:**
|
||||
|
||||
```bash
|
||||
bm cloud set-key bmc_abc123...
|
||||
```
|
||||
|
||||
**Option B: Create a key via CLI (requires OAuth login first):**
|
||||
|
||||
```bash
|
||||
bm cloud login # One-time OAuth login
|
||||
bm cloud create-key "my-laptop" # Creates key and saves it locally
|
||||
```
|
||||
|
||||
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
|
||||
|
||||
### Setting Project Modes
|
||||
|
||||
```bash
|
||||
# Route a project through cloud
|
||||
bm project set-cloud research
|
||||
|
||||
# Revert to local mode
|
||||
bm project set-local research
|
||||
|
||||
# View project modes
|
||||
bm project list
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- `set-cloud`: validates the API key exists, then sets the project mode to `cloud` in config
|
||||
- `set-local`: reverts the project to local mode (removes the mode entry from config)
|
||||
- MCP tools and CLI commands for that project will route to `cloud_host/proxy` with the API key as Bearer token
|
||||
|
||||
### How It Works
|
||||
|
||||
When an MCP tool or CLI command runs for a cloud-mode project:
|
||||
|
||||
1. `get_client(project_name="research")` checks the project's mode in config
|
||||
2. If mode is `cloud`, creates an HTTP client pointed at `cloud_host/proxy` with `Authorization: Bearer bmc_...`
|
||||
3. If mode is `local` (default), uses the in-process ASGI transport as usual
|
||||
|
||||
**Routing priority** (highest to lowest):
|
||||
1. Factory injection (cloud app, tests)
|
||||
2. Explicit route override (`--local` / `--cloud`)
|
||||
3. Per-project cloud mode (API key)
|
||||
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
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"personal": "/Users/me/notes",
|
||||
"research": "/Users/me/research"
|
||||
},
|
||||
"project_modes": {
|
||||
"research": "cloud"
|
||||
},
|
||||
"cloud_api_key": "bmc_abc123...",
|
||||
"cloud_host": "https://cloud.basicmemory.com",
|
||||
"default_project": "personal"
|
||||
}
|
||||
```
|
||||
|
||||
In this example, `personal` stays local and `research` routes through cloud. Projects not listed in `project_modes` default to local.
|
||||
|
||||
### Sync Behavior
|
||||
|
||||
Cloud-mode projects are automatically skipped during local file sync (background sync and file watching). Their files live on the cloud instance, not locally.
|
||||
|
||||
## OAuth Logout
|
||||
Return to local mode:
|
||||
|
||||
```bash
|
||||
bm cloud logout
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
1. Removes stored OAuth token(s)
|
||||
2. Does not change per-project route configuration
|
||||
3. Does not change command routing defaults
|
||||
1. Disables cloud mode in config
|
||||
2. All commands now work locally
|
||||
3. Auth token remains (can re-enable with login)
|
||||
|
||||
**Result:** OAuth session is cleared. API-key-based routing still works if `cloud_api_key` is configured.
|
||||
**Result:** All `bm` commands work with local projects again.
|
||||
|
||||
## Filter Configuration
|
||||
|
||||
@@ -766,20 +653,12 @@ If instance is down, wait a few minutes and retry.
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Cloud Authentication
|
||||
### Cloud Mode Management
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### API Key Management
|
||||
|
||||
```bash
|
||||
bm cloud set-key <key> # Save a cloud API key (bmc_ prefixed)
|
||||
bm cloud create-key <name> # Create API key via cloud API (requires OAuth login)
|
||||
bm cloud login # Authenticate and enable cloud mode
|
||||
bm cloud logout # Disable cloud mode
|
||||
bm cloud status # Check cloud mode and instance health
|
||||
```
|
||||
|
||||
### Setup
|
||||
@@ -790,22 +669,16 @@ bm cloud setup # Install rclone and configure credentials
|
||||
|
||||
### Project Management
|
||||
|
||||
When cloud mode is enabled:
|
||||
|
||||
```bash
|
||||
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 list # List cloud projects
|
||||
bm project add <name> # Create cloud project (no sync)
|
||||
bm project add <name> --local-path <path> # Create with local sync
|
||||
bm project sync-setup <name> <path> # Add sync to existing project
|
||||
bm project rm <name> # Delete project
|
||||
```
|
||||
|
||||
### Per-Project Routing
|
||||
|
||||
```bash
|
||||
bm project set-cloud <name> # Route project through cloud (requires API key)
|
||||
bm project set-local <name> # Revert project to local mode
|
||||
```
|
||||
|
||||
### File Synchronization
|
||||
|
||||
```bash
|
||||
@@ -824,20 +697,18 @@ bm project bisync --name <project> --verbose
|
||||
bm project check --name <project>
|
||||
bm project check --name <project> --one-way
|
||||
|
||||
# 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>
|
||||
# List remote files
|
||||
bm project ls --name <project>
|
||||
bm project ls --name <project> --path <subpath>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
**Basic Memory Cloud uses project-scoped sync:**
|
||||
|
||||
1. **Authenticate cloud access** - `bm cloud login`
|
||||
1. **Enable cloud mode** - `bm cloud login`
|
||||
2. **Install rclone** - `bm cloud setup`
|
||||
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
|
||||
3. **Add projects with sync** - `bm project add research --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`
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Cloud Semantic Search Value (Customer-Facing Technical Story)
|
||||
|
||||
This document explains why teams should buy cloud semantic search even when local search exists.
|
||||
|
||||
## Core Promise
|
||||
|
||||
Markdown files remain the source of truth in both local and cloud modes.
|
||||
|
||||
- Files are portable.
|
||||
- Search indexes are derived and rebuildable.
|
||||
- You never get locked into proprietary document storage.
|
||||
|
||||
## The Customer Problem
|
||||
|
||||
Teams paying for cloud are usually not optimizing for "can this run locally." They are optimizing for:
|
||||
|
||||
- finding the right note the first time,
|
||||
- keeping retrieval quality high as note volume grows,
|
||||
- avoiding search slowdowns while content is actively changing,
|
||||
- getting consistent results across users, agents, and sessions.
|
||||
|
||||
## Why Cloud Is the Aspirin
|
||||
|
||||
Cloud semantic search is the immediate pain reliever because it fixes the problems users feel right now.
|
||||
|
||||
### 1) Better hit rate on real queries
|
||||
|
||||
Cloud uses stronger managed embeddings than the default local model, which improves semantic recall for paraphrases and vague questions.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "I know this exists but search missed it" moments,
|
||||
- less query rewording,
|
||||
- faster time to answer.
|
||||
|
||||
### 2) Better behavior under active workloads
|
||||
|
||||
Cloud indexing runs out of band in workers, so indexing does not compete with interactive read/write traffic.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- stable search responsiveness during heavy updates,
|
||||
- fresher semantic results shortly after edits,
|
||||
- less user-visible performance variance.
|
||||
|
||||
### 3) Better consistency for shared knowledge
|
||||
|
||||
Cloud retrieval runs against a centralized tenant index, so teams and agents resolve against the same semantic state.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "works on my machine" search differences,
|
||||
- more predictable agent behavior across environments,
|
||||
- easier cross-user collaboration on large knowledge bases.
|
||||
|
||||
### 4) Better quality at higher scale
|
||||
|
||||
With Postgres + `pgvector` per tenant, cloud can sustain larger note collections and higher query volumes than typical local setups.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- confidence as repositories grow to tens of thousands of notes,
|
||||
- less need for user-side tuning,
|
||||
- fewer quality regressions as usage increases.
|
||||
|
||||
## Local Is the Vitamin
|
||||
|
||||
Local semantic search still matters and should stay strong.
|
||||
|
||||
- offline use,
|
||||
- privacy-first operation,
|
||||
- no cloud dependency,
|
||||
- user-controlled runtime.
|
||||
|
||||
It compounds long-term ownership and resilience, but does not remove the immediate pain points cloud solves for teams at scale.
|
||||
|
||||
## Recommended Messaging
|
||||
|
||||
One-liner:
|
||||
|
||||
"Cloud semantic search is the aspirin: it fixes retrieval quality and performance pain now. Local semantic search is the vitamin: it builds long-term control and resilience."
|
||||
|
||||
Long form:
|
||||
|
||||
"Basic Memory keeps markdown as the source of truth everywhere. Local gives privacy and offline control. Cloud adds immediate, measurable improvements in search quality, consistency, and responsiveness for teams and agents running at scale."
|
||||
|
||||
## Packaging Guidance
|
||||
|
||||
- Base: local FTS plus optional local semantic search.
|
||||
- Cloud value: higher semantic quality, stable performance under load, and consistent team-wide retrieval.
|
||||
- Keep interfaces pluggable (`EmbeddingProvider`, vector backend protocol) so implementation can evolve without changing user workflows.
|
||||
@@ -1,138 +0,0 @@
|
||||
# MCP UI Bakeoff - Instructions & Test Plan
|
||||
|
||||
Last updated: 2026-02-02
|
||||
|
||||
## Scope
|
||||
|
||||
Compare three presentation paths for Basic Memory MCP tools:
|
||||
|
||||
1. **Tool‑UI (React)** via MCP App resources.
|
||||
2. **MCP‑UI Python SDK** embedded UI resources (legacy host path).
|
||||
3. **ASCII/ANSI** output for TUI clients.
|
||||
|
||||
This doc is the running instruction set and test plan. Update as implementation progresses.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
|
||||
- Node for tool‑ui build (already used for POC)
|
||||
- Python 3.12+ with `uv`
|
||||
|
||||
Optional (for MCP‑UI Python SDK path):
|
||||
|
||||
- Local repo: `/Users/phernandez/dev/mcp-ui`
|
||||
- Install the server SDK into the Basic Memory venv:
|
||||
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
|
||||
|
||||
---
|
||||
|
||||
## Build / Refresh Steps
|
||||
|
||||
### Tool‑UI React bundle
|
||||
|
||||
```bash
|
||||
cd ui/tool-ui-react
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This regenerates:
|
||||
|
||||
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
|
||||
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
|
||||
|
||||
---
|
||||
|
||||
## How to Run the MCP Server
|
||||
|
||||
```bash
|
||||
basic-memory mcp --transport stdio
|
||||
```
|
||||
|
||||
Optional to pick UI variant for MCP App resources:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1) MCP App Resource UI (tool‑ui / vanilla / mcp‑ui)
|
||||
|
||||
Tools:
|
||||
- `search_notes`
|
||||
- `read_note`
|
||||
|
||||
Expect:
|
||||
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
|
||||
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
|
||||
- Variant‑specific URIs also available:
|
||||
- `ui://basic-memory/search-results/vanilla`
|
||||
- `ui://basic-memory/search-results/tool-ui`
|
||||
- `ui://basic-memory/search-results/mcp-ui`
|
||||
- `ui://basic-memory/note-preview/vanilla`
|
||||
- `ui://basic-memory/note-preview/tool-ui`
|
||||
- `ui://basic-memory/note-preview/mcp-ui`
|
||||
|
||||
Manual check:
|
||||
- Trigger tool in MCP‑App‑capable host and confirm UI renders.
|
||||
|
||||
---
|
||||
|
||||
### 2) Text / JSON Output Modes
|
||||
|
||||
Tools:
|
||||
- `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:
|
||||
- `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_json_integration.py`
|
||||
|
||||
---
|
||||
|
||||
### 3) MCP‑UI Python SDK (embedded UI resource)
|
||||
|
||||
Tools (embedded resource responses):
|
||||
- `search_notes_ui` (MCP‑UI SDK)
|
||||
- `read_note_ui` (MCP‑UI SDK)
|
||||
|
||||
Expected output:
|
||||
- Tool response content contains an EmbeddedResource (`type: "resource"`)
|
||||
- `mimeType` is `text/html`
|
||||
- `_meta` includes:
|
||||
- `mcpui.dev/ui-preferred-frame-size`
|
||||
- `mcpui.dev/ui-initial-render-data`
|
||||
|
||||
Manual check:
|
||||
- Render tool responses using `UIResourceRenderer` (legacy host flow).
|
||||
|
||||
Automated (if SDK installed):
|
||||
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
---
|
||||
|
||||
## Bakeoff Notes Template
|
||||
|
||||
Fill in after running:
|
||||
|
||||
- Tool‑UI (React): __
|
||||
- MCP‑UI SDK (embedded): __
|
||||
- Text/JSON modes: __
|
||||
|
||||
Decision + rationale: __
|
||||
@@ -1,344 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,209 +0,0 @@
|
||||
# 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
|
||||
@@ -1,265 +0,0 @@
|
||||
# Semantic Search
|
||||
|
||||
This guide covers Basic Memory's optional semantic (vector) search feature, which adds meaning-based retrieval alongside the existing full-text search.
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory's default search uses full-text search (FTS) — keyword matching with boolean operators. Semantic search adds vector embeddings that capture the *meaning* of your content, enabling:
|
||||
|
||||
- **Paraphrase matching**: Find "authentication flow" when searching for "login process"
|
||||
- **Conceptual queries**: Search for "ways to improve performance" and find notes about caching, indexing, and optimization
|
||||
- **Hybrid retrieval**: Combine the precision of keyword search with the recall of semantic similarity
|
||||
|
||||
Semantic search is **opt-in** — existing behavior is completely unchanged unless you enable it. It works on both SQLite (local) and Postgres (cloud) backends.
|
||||
|
||||
## Installation
|
||||
|
||||
Semantic search dependencies (fastembed, sqlite-vec, openai) are **optional extras** — they are not installed with the base `basic-memory` package. Install them with:
|
||||
|
||||
```bash
|
||||
pip install 'basic-memory[semantic]'
|
||||
```
|
||||
|
||||
This keeps the base install lightweight and avoids platform-specific issues with ONNX Runtime wheels.
|
||||
|
||||
### Platform Compatibility
|
||||
|
||||
| Platform | FastEmbed (local) | OpenAI (API) |
|
||||
|---|---|---|
|
||||
| macOS ARM64 (Apple Silicon) | Yes | Yes |
|
||||
| macOS x86_64 (Intel Mac) | No — see workaround below | Yes |
|
||||
| Linux x86_64 | Yes | Yes |
|
||||
| Linux ARM64 | Yes | Yes |
|
||||
| Windows x86_64 | Yes | Yes |
|
||||
|
||||
#### Intel Mac Workaround
|
||||
|
||||
The default FastEmbed provider uses ONNX Runtime, which dropped Intel Mac (x86_64) wheels starting in v1.24. Intel Mac users have two options:
|
||||
|
||||
**Option 1: Use OpenAI embeddings (recommended)**
|
||||
|
||||
Install only the OpenAI dependency manually — no ONNX Runtime or FastEmbed needed:
|
||||
|
||||
```bash
|
||||
pip install openai sqlite-vec
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
**Option 2: Pin an older ONNX Runtime**
|
||||
|
||||
FastEmbed's ONNX Runtime dependency is unpinned, so you can constrain it to an older version that still ships Intel Mac wheels by passing both requirements in the same install command:
|
||||
|
||||
```bash
|
||||
pip install 'basic-memory[semantic]' 'onnxruntime<1.24'
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install semantic extras:
|
||||
|
||||
```bash
|
||||
pip install 'basic-memory[semantic]'
|
||||
```
|
||||
|
||||
2. Enable semantic search:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
3. Build vector embeddings for your existing content:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
4. Search using semantic modes:
|
||||
|
||||
```python
|
||||
# Pure vector similarity
|
||||
search_notes("login process", search_type="vector")
|
||||
|
||||
# Hybrid: combines FTS precision with vector recall (recommended)
|
||||
search_notes("login process", search_type="hybrid")
|
||||
|
||||
# Traditional full-text search (still the default)
|
||||
search_notes("login process", search_type="text")
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
All settings are fields on `BasicMemoryConfig` and can be set via environment variables (prefixed with `BASIC_MEMORY_`).
|
||||
|
||||
| Config Field | Env Var | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | `false` | Enable semantic search. Required before vector/hybrid modes work. |
|
||||
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
|
||||
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
|
||||
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
|
||||
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
|
||||
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
|
||||
|
||||
## Embedding Providers
|
||||
|
||||
### FastEmbed (default)
|
||||
|
||||
FastEmbed runs entirely locally using ONNX models — no API key, no network calls, no cost.
|
||||
|
||||
- **Model**: `BAAI/bge-small-en-v1.5`
|
||||
- **Dimensions**: 384
|
||||
- **Tradeoff**: Smaller model, fast inference, good quality for most use cases
|
||||
|
||||
```bash
|
||||
# Install semantic extras and enable
|
||||
pip install 'basic-memory[semantic]'
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
Uses OpenAI's embeddings API for higher-dimensional vectors. Requires an API key.
|
||||
|
||||
- **Model**: `text-embedding-3-small`
|
||||
- **Dimensions**: 1536
|
||||
- **Tradeoff**: Higher quality embeddings, requires API calls and an OpenAI key
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
## Search Modes
|
||||
|
||||
### `text` (default)
|
||||
|
||||
Full-text keyword search using FTS5 (SQLite) or tsvector (Postgres). Supports boolean operators (`AND`, `OR`, `NOT`), phrase matching, and prefix wildcards.
|
||||
|
||||
```python
|
||||
search_notes("project AND planning", search_type="text")
|
||||
```
|
||||
|
||||
This is the existing default and does not require semantic search to be enabled.
|
||||
|
||||
### `vector`
|
||||
|
||||
Pure semantic similarity search. Embeds your query and finds the nearest content vectors. Good for conceptual or paraphrase queries where exact keywords may not appear in the content.
|
||||
|
||||
```python
|
||||
search_notes("how to speed up the app", search_type="vector")
|
||||
```
|
||||
|
||||
Returns results ranked by cosine similarity. Individual observations and relations surface as first-class results, not collapsed into parent entities.
|
||||
|
||||
### `hybrid`
|
||||
|
||||
Combines FTS and vector results using reciprocal rank fusion (RRF). This is generally the best mode when you want both keyword precision and semantic recall.
|
||||
|
||||
```python
|
||||
search_notes("authentication security", search_type="hybrid")
|
||||
```
|
||||
|
||||
RRF merges the two ranked lists so that items appearing in both get a score boost, while items found by only one method still appear.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Mode | Best For |
|
||||
|---|---|
|
||||
| `text` | Exact keyword matching, boolean queries, tag/category searches |
|
||||
| `vector` | Conceptual queries, paraphrase matching, exploratory searches |
|
||||
| `hybrid` | General-purpose search combining precision and recall |
|
||||
|
||||
## The Reindex Command
|
||||
|
||||
The `bm reindex` command rebuilds search indexes without dropping the database.
|
||||
|
||||
```bash
|
||||
# Rebuild everything (FTS + embeddings if semantic is enabled)
|
||||
bm reindex
|
||||
|
||||
# Only rebuild vector embeddings
|
||||
bm reindex --embeddings
|
||||
|
||||
# Only rebuild the full-text search index
|
||||
bm reindex --search
|
||||
|
||||
# Target a specific project
|
||||
bm reindex -p my-project
|
||||
```
|
||||
|
||||
### When You Need to Reindex
|
||||
|
||||
- **First enable**: After turning on `semantic_search_enabled` for the first time
|
||||
- **Provider change**: After switching between `fastembed` and `openai`
|
||||
- **Model change**: After changing `semantic_embedding_model`
|
||||
- **Dimension change**: After changing `semantic_embedding_dimensions`
|
||||
|
||||
The reindex command shows progress with embedded/skipped/error counts:
|
||||
|
||||
```
|
||||
Project: main
|
||||
Building vector embeddings...
|
||||
✓ Embeddings complete: 142 entities embedded, 0 skipped, 0 errors
|
||||
|
||||
Reindex complete!
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Chunking
|
||||
|
||||
Each entity in the search index is split into semantic chunks before embedding:
|
||||
|
||||
- **Headers**: Markdown headers (`#`, `##`, etc.) start new chunks
|
||||
- **Bullets**: Each bullet item (`-`, `*`) becomes its own chunk for granular fact retrieval
|
||||
- **Prose sections**: Non-bullet text is merged up to ~900 characters per chunk
|
||||
- **Long sections**: Oversized content is split with ~120 character overlap to preserve context at boundaries
|
||||
|
||||
Each search index item type (entity, observation, relation) is chunked independently, so observations and relations are embeddable as discrete facts.
|
||||
|
||||
### Deduplication
|
||||
|
||||
Each chunk has a `source_hash` (SHA-256 of the chunk text). On re-sync, unchanged chunks skip re-embedding entirely. This makes incremental updates fast — only modified content triggers API calls or model inference.
|
||||
|
||||
### Hybrid Fusion
|
||||
|
||||
Hybrid search uses reciprocal rank fusion (RRF) to merge FTS and vector results:
|
||||
|
||||
1. Run FTS search to get keyword-ranked results
|
||||
2. Run vector search to get similarity-ranked results
|
||||
3. For each result, compute: `score = 1/(k + fts_rank) + 1/(k + vector_rank)` where `k = 60`
|
||||
4. Sort by fused score
|
||||
|
||||
Items found by both methods get a natural score boost. Items found by only one method still appear but rank lower.
|
||||
|
||||
### Observation-Level Results
|
||||
|
||||
Vector and hybrid modes return individual observations and relations as first-class search results, not just parent entities. This means a search for "water temperature for brewing" can surface the specific observation about 205°F without returning the entire "Coffee Brewing Methods" entity.
|
||||
|
||||
## Database Backends
|
||||
|
||||
### SQLite (local)
|
||||
|
||||
- **Vector storage**: [sqlite-vec](https://github.com/asg017/sqlite-vec) virtual table
|
||||
- **Table creation**: At runtime when semantic search is first used — no migration needed
|
||||
- **Embedding table**: `search_vector_embeddings` using `vec0(embedding float[N])` where N is the configured dimensions
|
||||
- **Chunk metadata**: `search_vector_chunks` table stores chunk text, keys, and source hashes
|
||||
|
||||
The sqlite-vec extension is loaded per-connection. Vector tables are created lazily on first use.
|
||||
|
||||
### Postgres (cloud)
|
||||
|
||||
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
|
||||
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
|
||||
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
|
||||
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
|
||||
|
||||
The Alembic migration creates the dimension-independent chunks table. The embeddings table and HNSW index are deferred to runtime because they depend on the configured vector dimensions.
|
||||
@@ -1,225 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
# SPEC-SCHEMA-IMPL: Schema System Implementation Plan
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
**Depends on:** [SPEC-SCHEMA](SPEC-SCHEMA.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Implementation plan for the Basic Memory Schema System. The system is entirely programmatic —
|
||||
no LLM agent runtime or API key required. The LLM already in the user's session (Claude Code,
|
||||
Claude Desktop, etc.) provides the intelligence layer by reading schema notes via existing
|
||||
MCP tools.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Entry Points │
|
||||
│ CLI (bm schema ...) │ MCP (schema_validate) │
|
||||
└──────────┬────────────┴──────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Schema Service Layer │
|
||||
│ resolve_schema · validate · infer · diff │
|
||||
└──────────┬────────────────────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ Picoschema Parser │ │ Note/Entity Access │
|
||||
│ YAML → SchemaModel │ │ (existing repository) │
|
||||
└──────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
No new database tables. Schemas are notes with `type: schema` — they're already indexed.
|
||||
Validation reads observations and relations from existing data.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Picoschema Parser
|
||||
|
||||
**Location:** `src/basic_memory/schema/parser.py`
|
||||
|
||||
Parses Picoschema YAML into an internal representation.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaField:
|
||||
name: str
|
||||
type: str # string, integer, number, boolean, any, or EntityName
|
||||
required: bool # True unless field name ends with ?
|
||||
is_array: bool # True if (array) notation
|
||||
is_enum: bool # True if (enum) notation
|
||||
enum_values: list[str] # Populated for enums
|
||||
description: str | None # Text after comma
|
||||
is_entity_ref: bool # True if type is capitalized (entity reference)
|
||||
children: list[SchemaField] # For (object) types
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaDefinition:
|
||||
entity: str # The entity type this schema describes
|
||||
version: int # Schema version
|
||||
fields: list[SchemaField] # Parsed fields
|
||||
validation_mode: str # "warn" | "strict" | "off"
|
||||
|
||||
|
||||
def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
|
||||
"""Parse a Picoschema YAML dict into a list of SchemaField objects."""
|
||||
|
||||
|
||||
def parse_schema_note(frontmatter: dict) -> SchemaDefinition:
|
||||
"""Parse a full schema note's frontmatter into a SchemaDefinition."""
|
||||
```
|
||||
|
||||
**Input/Output:**
|
||||
```yaml
|
||||
# Input (YAML dict from frontmatter)
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
```
|
||||
|
||||
```python
|
||||
# Output
|
||||
[
|
||||
SchemaField(name="name", type="string", required=True, description="full name", ...),
|
||||
SchemaField(name="role", type="string", required=False, description="job title", ...),
|
||||
SchemaField(name="works_at", type="Organization", required=False, is_entity_ref=True, ...),
|
||||
SchemaField(name="expertise", type="string", required=False, is_array=True, ...),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Schema Resolver
|
||||
|
||||
**Location:** `src/basic_memory/schema/resolver.py`
|
||||
|
||||
Finds the applicable schema for a note using the resolution order.
|
||||
|
||||
```python
|
||||
async def resolve_schema(
|
||||
note_frontmatter: dict,
|
||||
search_fn: Callable, # injected search capability
|
||||
) -> SchemaDefinition | None:
|
||||
"""Resolve schema for a note.
|
||||
|
||||
Resolution order:
|
||||
1. Inline schema (frontmatter['schema'] is a dict)
|
||||
2. Explicit reference (frontmatter['schema'] is a string)
|
||||
3. Implicit by type (frontmatter['type'] → schema note with matching entity)
|
||||
4. No schema (returns None)
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Schema Validator
|
||||
|
||||
**Location:** `src/basic_memory/schema/validator.py`
|
||||
|
||||
Validates a note's observations and relations against a resolved schema.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldResult:
|
||||
field: SchemaField
|
||||
status: str # "present" | "missing" | "type_mismatch"
|
||||
values: list[str] # Matched observation values or relation targets
|
||||
message: str | None # Human-readable detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
note_identifier: str
|
||||
schema_entity: str
|
||||
passed: bool # True if no errors (warnings are OK)
|
||||
field_results: list[FieldResult]
|
||||
unmatched_observations: dict[str, int] # category → count
|
||||
unmatched_relations: list[str] # relation types not in schema
|
||||
warnings: list[str]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
async def validate_note(
|
||||
note: Note,
|
||||
schema: SchemaDefinition,
|
||||
) -> ValidationResult:
|
||||
"""Validate a note against a schema definition.
|
||||
|
||||
Mapping rules:
|
||||
- field: string → observation [field] exists
|
||||
- field?(array): type → multiple [field] observations
|
||||
- field?: EntityType → relation 'field [[...]]' exists
|
||||
- field?(enum): [v] → observation [field] value ∈ enum values
|
||||
"""
|
||||
```
|
||||
|
||||
### 4. Schema Inference Engine
|
||||
|
||||
**Location:** `src/basic_memory/schema/inference.py`
|
||||
|
||||
Analyzes notes of a given type and suggests a schema based on usage frequency.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldFrequency:
|
||||
name: str
|
||||
source: str # "observation" | "relation"
|
||||
count: int # notes containing this field
|
||||
total: int # total notes analyzed
|
||||
percentage: float
|
||||
sample_values: list[str] # representative values
|
||||
is_array: bool # True if typically appears multiple times per note
|
||||
target_type: str | None # For relations, the most common target entity type
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceResult:
|
||||
entity_type: str
|
||||
notes_analyzed: int
|
||||
field_frequencies: list[FieldFrequency]
|
||||
suggested_schema: dict # Ready-to-use Picoschema YAML dict
|
||||
suggested_required: list[str]
|
||||
suggested_optional: list[str]
|
||||
excluded: list[str] # Below threshold
|
||||
|
||||
|
||||
async def infer_schema(
|
||||
entity_type: str,
|
||||
notes: list[Note],
|
||||
required_threshold: float = 0.95, # 95%+ = required
|
||||
optional_threshold: float = 0.25, # 25%+ = optional
|
||||
) -> InferenceResult:
|
||||
"""Analyze notes and suggest a Picoschema definition."""
|
||||
```
|
||||
|
||||
### 5. Schema Diff
|
||||
|
||||
**Location:** `src/basic_memory/schema/diff.py`
|
||||
|
||||
Compares current note usage against an existing schema definition.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaDrift:
|
||||
new_fields: list[FieldFrequency] # Fields not in schema but common in notes
|
||||
dropped_fields: list[FieldFrequency] # Fields in schema but rare in notes
|
||||
cardinality_changes: list[str] # one → many or many → one
|
||||
type_mismatches: list[str] # observation values don't match declared type
|
||||
|
||||
|
||||
async def diff_schema(
|
||||
schema: SchemaDefinition,
|
||||
notes: list[Note],
|
||||
) -> SchemaDrift:
|
||||
"""Compare a schema against actual note usage to detect drift."""
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### CLI Commands
|
||||
|
||||
**Location:** `src/basic_memory/cli/schema.py`
|
||||
|
||||
```python
|
||||
import typer
|
||||
|
||||
schema_app = typer.Typer(name="schema", help="Schema management commands")
|
||||
|
||||
@schema_app.command()
|
||||
async def validate(
|
||||
target: str = typer.Argument(None, help="Note path or entity type"),
|
||||
strict: bool = typer.Option(False, help="Override to strict mode"),
|
||||
):
|
||||
"""Validate notes against their schemas."""
|
||||
|
||||
@schema_app.command()
|
||||
async def infer(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to analyze"),
|
||||
threshold: float = typer.Option(0.25, help="Minimum frequency for optional fields"),
|
||||
save: bool = typer.Option(False, help="Save to schema/ directory"),
|
||||
):
|
||||
"""Infer schema from existing notes of a type."""
|
||||
|
||||
@schema_app.command()
|
||||
async def diff(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to diff"),
|
||||
):
|
||||
"""Show drift between schema and actual usage."""
|
||||
```
|
||||
|
||||
Registered as subcommand: `bm schema validate`, `bm schema infer`, `bm schema diff`.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
**Location:** `src/basic_memory/mcp/tools/schema.py`
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Validate notes against their resolved schema."""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Analyze existing notes and suggest a schema definition."""
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Location:** `src/basic_memory/api/schema_router.py`
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/schema", tags=["schema"])
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_schema(...) -> ValidationReport: ...
|
||||
|
||||
@router.post("/infer")
|
||||
async def infer_schema(...) -> InferenceResult: ...
|
||||
|
||||
@router.get("/diff/{entity_type}")
|
||||
async def diff_schema(...) -> SchemaDrift: ...
|
||||
```
|
||||
|
||||
MCP tools call these endpoints via the typed client pattern (consistent with existing
|
||||
architecture).
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Parser + Resolver
|
||||
|
||||
Build the foundation — can parse Picoschema and find schemas for notes.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/parser.py` — Picoschema YAML → `SchemaDefinition`
|
||||
- `schema/resolver.py` — Resolution order (inline → explicit ref → implicit by type → none)
|
||||
- Unit tests for all Picoschema syntax variations
|
||||
- Unit tests for resolution order
|
||||
|
||||
**No external dependencies.** Pure Python parsing of YAML dicts. Can develop and test
|
||||
in isolation.
|
||||
|
||||
### Phase 2: Validator
|
||||
|
||||
Connect schemas to notes and produce validation results.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/validator.py` — Validate note observations/relations against schema fields
|
||||
- API endpoint: `POST /schema/validate`
|
||||
- MCP tool: `schema_validate`
|
||||
- CLI command: `bm schema validate`
|
||||
- Integration tests with real notes and schemas
|
||||
|
||||
**Depends on:** Phase 1 (parser + resolver)
|
||||
|
||||
### Phase 3: Inference
|
||||
|
||||
Analyze existing notes to suggest schemas.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/inference.py` — Frequency analysis across notes of a type
|
||||
- API endpoint: `POST /schema/infer`
|
||||
- MCP tool: `schema_infer`
|
||||
- CLI command: `bm schema infer`
|
||||
- Option to save inferred schema as a note via `write_note`
|
||||
|
||||
**Depends on:** Phase 1 (parser for output format)
|
||||
|
||||
### Phase 4: Diff
|
||||
|
||||
Compare schemas against current usage.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/diff.py` — Drift detection between schema and actual notes
|
||||
- API endpoint: `GET /schema/diff/{entity_type}`
|
||||
- CLI command: `bm schema diff`
|
||||
|
||||
**Depends on:** Phase 1 (parser), Phase 3 (inference, for frequency analysis)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** (`tests/schema/`): Parser edge cases, resolution logic, validation mapping,
|
||||
inference thresholds
|
||||
- **Integration tests** (`test-int/schema/`): End-to-end with real markdown files, schema notes
|
||||
on disk, CLI invocation
|
||||
- Coverage target: 100% (consistent with project standard)
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- No new database tables or migrations
|
||||
- No new markdown syntax (schemas validate existing observations/relations)
|
||||
- No LLM agent runtime or API key management
|
||||
- No hook integration (deferred)
|
||||
- No schema composition/inheritance (deferred)
|
||||
- No OWL/RDF export (deferred)
|
||||
- No built-in templates (deferred)
|
||||
@@ -1,462 +0,0 @@
|
||||
# SPEC-SCHEMA: Basic Memory Schema System
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
|
||||
## Summary
|
||||
|
||||
A schema system for Basic Memory that uses [Picoschema](https://genkit.dev/docs/dotprompt/)
|
||||
syntax in YAML frontmatter. Schemas validate notes against their existing observation/relation
|
||||
structure — no new data model, no migration, just a declarative lens over what's already there.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Schemas are just notes** — A schema is a note with `type: schema`, lives anywhere
|
||||
2. **Use prior art** — Picoschema syntax in YAML frontmatter, no custom notation
|
||||
3. **Validation maps to existing format** — Observations and relations, not a parallel data model
|
||||
4. **Validation is soft** — Warnings by default, not blocking errors
|
||||
5. **Inference over prescription** — Schemas describe reality, emerge from usage
|
||||
6. **No built-in agent** — Programmatic core; the LLM already in the session provides intelligence
|
||||
|
||||
## Picoschema Syntax
|
||||
|
||||
Picoschema is a compact schema notation from Google's Dotprompt that fits naturally in YAML
|
||||
frontmatter.
|
||||
|
||||
### Supported Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `string` | Text value |
|
||||
| `integer` | Whole number |
|
||||
| `number` | Decimal number |
|
||||
| `boolean` | True/false |
|
||||
| `any` | Any scalar type |
|
||||
| `EntityName` | Reference to another entity (capitalized = entity reference) |
|
||||
|
||||
### Syntax Rules
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
- `field: type` — required field
|
||||
- `field?: type` — optional field
|
||||
- `field(array): type` — array of values
|
||||
- `field?(enum): [values]` — enumeration
|
||||
- `field?(object):` — nested object with sub-fields
|
||||
- `, description` — description after comma
|
||||
- `EntityName` as type (capitalized) — reference to another entity
|
||||
|
||||
## Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against the existing Basic Memory note format. No new syntax for note
|
||||
authors to learn.
|
||||
|
||||
### Mapping Rules
|
||||
|
||||
| Schema Declaration | Grounded In | Example Match |
|
||||
|--------------------|-------------|---------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (×N) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (×N) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [values]` | Observation `[field] value` where value ∈ set | `- [status] active` |
|
||||
|
||||
### Key Insight
|
||||
|
||||
Schemas don't introduce a new way to store data. They describe the patterns already present
|
||||
in observations and relations. A note doesn't have to change how it's written — the schema
|
||||
just says "a good Person note has a `[name]` observation and a `works_at` relation."
|
||||
|
||||
## Schema Definition
|
||||
|
||||
### As a Dedicated Schema Note
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
email?: string, contact email
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
settings:
|
||||
validation: warn # warn | strict | off
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
|
||||
Any documentation about this entity type goes here as prose.
|
||||
```
|
||||
|
||||
Schema notes are regular Basic Memory notes. They show up in search, can have their own
|
||||
observations and relations, and can be organized in any folder (though `schema/` is
|
||||
the suggested convention).
|
||||
|
||||
### Inline Schema in a Note
|
||||
|
||||
Notes can carry their own schema directly:
|
||||
|
||||
```yaml
|
||||
# meetings/2024-01-15-standup.md
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
### Explicit Schema Reference
|
||||
|
||||
A note can reference a schema by entity name or permalink:
|
||||
|
||||
```yaml
|
||||
# projects/basic-memory.md
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject # by entity name
|
||||
---
|
||||
|
||||
# research/llm-memory-patterns.md
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project # by permalink
|
||||
---
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- Note's `type` differs from the schema it should validate against
|
||||
- Multiple schema variants exist for the same domain
|
||||
- Applying structure to existing notes without changing their type
|
||||
|
||||
## Schema Resolution
|
||||
|
||||
When validating a note, schemas resolve in priority order:
|
||||
|
||||
```
|
||||
1. Inline schema → schema: { ... } (dict in frontmatter)
|
||||
2. Explicit ref → schema: Person (string in frontmatter)
|
||||
3. Implicit by type → type: Person (lookup schema note with entity: Person)
|
||||
4. No schema → no validation (perfectly fine)
|
||||
```
|
||||
|
||||
```python
|
||||
async def resolve_schema(note: Note) -> Schema | None:
|
||||
schema_value = note.frontmatter.get('schema')
|
||||
|
||||
# 1. Inline schema (dict)
|
||||
if isinstance(schema_value, dict):
|
||||
return parse_picoschema(schema_value)
|
||||
|
||||
# 2. Explicit reference (string)
|
||||
if isinstance(schema_value, str):
|
||||
schema_note = await find_schema_note(schema_value)
|
||||
if schema_note:
|
||||
return parse_picoschema(schema_note.frontmatter['schema'])
|
||||
|
||||
# 3. Implicit by type
|
||||
note_type = note.frontmatter.get('type')
|
||||
if note_type:
|
||||
results = await search_notes(f"type:schema entity:{note_type}")
|
||||
if results:
|
||||
return parse_picoschema(results[0].frontmatter['schema'])
|
||||
|
||||
# 4. No schema
|
||||
return None
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Modes
|
||||
|
||||
Configured in the schema's `settings.validation`:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `off` | No validation |
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
|
||||
### Validation Output
|
||||
|
||||
For a note missing required fields:
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
They're valid. Schemas are a subset, not a straitjacket.
|
||||
|
||||
### Batch Validation
|
||||
|
||||
```
|
||||
$ bm schema validate Person
|
||||
|
||||
Validating 30 notes against Person schema...
|
||||
|
||||
✓ people/paul-graham.md — all fields present
|
||||
✓ people/rich-hickey.md — all fields present
|
||||
⚠ people/ada-lovelace.md — missing: name
|
||||
⚠ people/alan-kay.md — missing: name, role
|
||||
✓ people/linus-torvalds.md — all fields present
|
||||
...
|
||||
|
||||
Summary: 22/30 valid, 8 warnings, 0 errors
|
||||
```
|
||||
|
||||
## Emerging Schemas
|
||||
|
||||
### The Problem with Traditional Schemas
|
||||
|
||||
Most schema systems require: define schema → create conforming content → fight the schema
|
||||
when reality doesn't match. This is backwards. Knowledge grows organically.
|
||||
|
||||
### The Basic Memory Approach
|
||||
|
||||
```
|
||||
Write notes freely → Patterns emerge → Crystallize into schema → Validate future notes
|
||||
```
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[fact] 25/30 83% (generic — no single field)
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
[born] 6/30 20% (below threshold)
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
authored 11/30 37% → authored?(array): string
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- 100% present → required field
|
||||
- 25%+ present → optional field
|
||||
- Below 25% → excluded from suggestion (but noted)
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## LLM Integration (AI Guidance)
|
||||
|
||||
No agent runtime or API key required. The LLM already in the session uses schemas as
|
||||
context for note creation.
|
||||
|
||||
### Flow
|
||||
|
||||
1. User asks LLM to "write a note about Rich Hickey"
|
||||
2. LLM determines `type: Person` is appropriate
|
||||
3. LLM calls `search_notes("type:schema entity:Person")` → finds schema
|
||||
4. LLM reads schema fields: required `name`, optional `role`, `works_at`, `expertise`
|
||||
5. LLM calls `write_note` with observations and relations that satisfy the schema
|
||||
|
||||
The schema acts as a creation template. The LLM knows what a "complete" note looks like
|
||||
without any custom agent infrastructure.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> ValidationReport:
|
||||
"""Validate notes against their resolved schema.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
"""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> SuggestedSchema:
|
||||
"""Analyze existing notes and suggest a schema definition.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Validate a specific note
|
||||
bm schema validate people/ada-lovelace.md
|
||||
|
||||
# Validate all notes of a type
|
||||
bm schema validate Person
|
||||
|
||||
# Validate everything with a schema
|
||||
bm schema validate
|
||||
|
||||
# Infer schema from existing notes
|
||||
bm schema infer Person
|
||||
|
||||
# Show schema drift from current definition
|
||||
bm schema diff Person
|
||||
|
||||
# List all schema notes
|
||||
bm search "type:schema"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Person Workflow
|
||||
|
||||
**Schema:**
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
**Valid note:**
|
||||
```yaml
|
||||
# people/paul-graham.md
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
**Note with warnings:**
|
||||
```yaml
|
||||
# people/ada-lovelace.md
|
||||
---
|
||||
title: Ada Lovelace
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Ada Lovelace
|
||||
|
||||
## Observations
|
||||
- [fact] Wrote the first computer program
|
||||
- [born] 1815
|
||||
|
||||
## Relations
|
||||
- collaborated_with [[Charles Babbage]]
|
||||
```
|
||||
|
||||
Validation: warns about missing required `[name]` observation. Everything else is optional
|
||||
or unmatched (which is fine).
|
||||
|
||||
## Future Considerations (Deferred)
|
||||
|
||||
These are interesting but out of scope for the initial implementation:
|
||||
|
||||
- **Multiple schema inheritance** — `schema: [Person, Author]`
|
||||
- **Hook integration** — Pre-write validation via the hooks system
|
||||
- **OWL/RDF export** — `bm schema export --format owl`
|
||||
- **SPARQL queries** — Schema-aware graph queries
|
||||
- **Built-in templates** — `bm schema use gtd`, `bm schema use zettelkasten`
|
||||
- **Schema versioning/migration** — Tracking breaking changes across versions
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
uv sync --extra semantic
|
||||
uv pip install -e ".[dev]"
|
||||
uv sync
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
@@ -42,9 +43,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 (excludes semantic benchmarks — use just test-semantic)
|
||||
# Run integration tests against SQLite
|
||||
test-int-sqlite:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
|
||||
# Run integration tests against Postgres
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
@@ -55,10 +56,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_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int' || test $? -eq 137
|
||||
$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
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
fi
|
||||
|
||||
# Run tests impacted by recent changes (requires pytest-testmon)
|
||||
@@ -98,43 +99,18 @@ postgres-migrate:
|
||||
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
|
||||
# Will be skipped automatically on non-Windows platforms
|
||||
test-windows:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
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:
|
||||
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:
|
||||
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl
|
||||
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl --format markdown --show-missing
|
||||
benchmark-compare baseline candidate *args:
|
||||
uv run python test-int/compare_search_benchmarks.py "{{baseline}}" "{{candidate}}" --format table {{args}}
|
||||
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
|
||||
# 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:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
@@ -209,9 +185,6 @@ 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}}"
|
||||
|
||||
@@ -46,12 +46,6 @@ dependencies = [
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
semantic = [
|
||||
"fastembed>=0.7.4",
|
||||
"sqlite-vec>=0.1.6",
|
||||
"openai>=1.100.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/basicmachines-co/basic-memory"
|
||||
@@ -78,7 +72,6 @@ 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]
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.18.3",
|
||||
"version": "0.18.2",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.18.3",
|
||||
"version": "0.18.2",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.18.3"
|
||||
__version__ = "0.18.2"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Add Postgres semantic vector search tables (pgvector-aware, optional)
|
||||
|
||||
Revision ID: h1b2c3d4e5f6
|
||||
Revises: d7e8f9a0b1c2
|
||||
Create Date: 2026-02-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "h1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "d7e8f9a0b1c2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create Postgres vector chunk metadata table.
|
||||
|
||||
Trigger: database backend is PostgreSQL.
|
||||
Why: search_vector_chunks stores text metadata with no vector-dimension
|
||||
dependency, so it's safe in a migration. search_vector_embeddings (which
|
||||
requires pgvector and a provider-specific dimension) is created at runtime
|
||||
by PostgresSearchRepository._ensure_vector_tables(), mirroring the SQLite
|
||||
pattern where vector tables are created dynamically.
|
||||
Outcome: creates the dimension-independent chunks table. The embeddings
|
||||
table + HNSW index are deferred to runtime.
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS search_vector_chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
chunk_key TEXT NOT NULL,
|
||||
chunk_text TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, entity_id, chunk_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_search_vector_chunks_project_entity
|
||||
ON search_vector_chunks (project_id, entity_id)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove Postgres vector chunk/embedding tables.
|
||||
|
||||
Does not drop pgvector extension because other schema objects may depend on it.
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute("DROP TABLE IF EXISTS search_vector_embeddings")
|
||||
op.execute("DROP TABLE IF EXISTS search_vector_chunks")
|
||||
@@ -18,7 +18,6 @@ from basic_memory.api.v2.routers import (
|
||||
directory_router as v2_directory,
|
||||
prompt_router as v2_prompt,
|
||||
importer_router as v2_importer,
|
||||
schema_router as v2_schema,
|
||||
)
|
||||
from basic_memory.api.v2.routers.project_router import (
|
||||
add_project,
|
||||
@@ -85,7 +84,6 @@ app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Legacy web app proxy paths (compat with /proxy/projects/projects)
|
||||
|
||||
@@ -47,6 +47,7 @@ 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,7 +8,6 @@ from basic_memory.api.v2.routers.resource_router import router as resource_route
|
||||
from basic_memory.api.v2.routers.directory_router import router as directory_router
|
||||
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
|
||||
from basic_memory.api.v2.routers.importer_router import router as importer_router
|
||||
from basic_memory.api.v2.routers.schema_router import router as schema_router
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
@@ -19,5 +18,4 @@ __all__ = [
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
"schema_router",
|
||||
]
|
||||
|
||||
@@ -39,23 +39,6 @@ from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteRe
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
|
||||
def _schedule_vector_sync_if_enabled(
|
||||
*,
|
||||
task_scheduler,
|
||||
app_config,
|
||||
entity_id: int,
|
||||
project_id: int,
|
||||
) -> None:
|
||||
"""Schedule out-of-band vector sync only when semantic search is enabled."""
|
||||
if app_config.semantic_search_enabled:
|
||||
task_scheduler.schedule(
|
||||
"sync_entity_vectors",
|
||||
entity_id=entity_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
## Resolution endpoint
|
||||
|
||||
|
||||
@@ -186,7 +169,6 @@ async def create_entity(
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
@@ -213,13 +195,7 @@ async def create_entity(
|
||||
)
|
||||
else:
|
||||
entity = await entity_service.create_entity(data)
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
@@ -249,7 +225,6 @@ async def update_entity_by_id(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
@@ -302,13 +277,7 @@ async def update_entity_by_id(
|
||||
)
|
||||
response.status_code = 201
|
||||
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
@@ -334,7 +303,6 @@ async def edit_entity_by_id(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
@@ -391,13 +359,7 @@ async def edit_entity_by_id(
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
await search_service.index_entity(updated_entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
if fast:
|
||||
@@ -472,7 +434,6 @@ async def move_entity(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Move an entity to a new file location.
|
||||
@@ -511,13 +472,7 @@ async def move_entity(
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=reindexed_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
@@ -544,7 +499,6 @@ async def move_directory(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
@@ -576,13 +530,7 @@ async def move_directory(
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: move_directory "
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
"""V2 router for schema operations.
|
||||
|
||||
Provides endpoints for schema validation, inference, and drift detection.
|
||||
The schema system validates notes against Picoschema definitions without
|
||||
introducing any new data model -- it works entirely with existing
|
||||
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 EntityRepositoryV2ExternalDep
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.schemas.schema import (
|
||||
ValidationReport,
|
||||
InferenceReport,
|
||||
DriftReport,
|
||||
NoteValidationResponse,
|
||||
FieldResultResponse,
|
||||
FieldFrequencyResponse,
|
||||
DriftFieldResponse,
|
||||
)
|
||||
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"])
|
||||
|
||||
|
||||
# --- ORM to core data conversion ---
|
||||
|
||||
|
||||
def _entity_observations(entity: Entity) -> list[ObservationData]:
|
||||
"""Extract ObservationData from an entity's observations."""
|
||||
return [ObservationData(obs.category, obs.content) for obs in entity.observations]
|
||||
|
||||
|
||||
def _entity_relations(entity: Entity) -> list[RelationData]:
|
||||
"""Extract RelationData from an entity's outgoing relations.
|
||||
|
||||
Carries the target entity's type on each relation so the inference engine
|
||||
can suggest correct types (e.g. works_at -> Organization, not the source type).
|
||||
"""
|
||||
return [
|
||||
RelationData(
|
||||
relation_type=rel.relation_type,
|
||||
target_name=rel.to_name,
|
||||
target_entity_type=rel.to_entity.entity_type if rel.to_entity else None,
|
||||
)
|
||||
for rel in entity.outgoing_relations
|
||||
]
|
||||
|
||||
|
||||
def _entity_to_note_data(entity: Entity) -> NoteData:
|
||||
"""Convert an ORM Entity to a NoteData for inference/diff analysis."""
|
||||
return NoteData(
|
||||
identifier=entity.permalink or entity.file_path,
|
||||
observations=_entity_observations(entity),
|
||||
relations=_entity_relations(entity),
|
||||
)
|
||||
|
||||
|
||||
def _entity_frontmatter(entity: Entity) -> dict:
|
||||
"""Build a frontmatter dict from an entity for schema resolution."""
|
||||
frontmatter = dict(entity.entity_metadata) if entity.entity_metadata else {}
|
||||
if entity.entity_type:
|
||||
frontmatter.setdefault("type", entity.entity_type)
|
||||
return frontmatter
|
||||
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
|
||||
@router.post("/schema/validate", response_model=ValidationReport)
|
||||
async def validate_schema(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
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"),
|
||||
):
|
||||
"""Validate notes against their resolved schemas.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
"""
|
||||
results: list[NoteValidationResponse] = []
|
||||
|
||||
# --- 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=[])
|
||||
|
||||
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,
|
||||
schema_def,
|
||||
_entity_observations(entity),
|
||||
_entity_relations(entity),
|
||||
)
|
||||
results.append(_to_note_validation_response(result))
|
||||
|
||||
return ValidationReport(
|
||||
entity_type=entity_type or entity.entity_type,
|
||||
total_notes=1,
|
||||
valid_count=1 if (results and results[0].passed) else 0,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
error_count=sum(len(r.errors) for r in results),
|
||||
results=results,
|
||||
)
|
||||
|
||||
# --- Batch validation by entity type ---
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type) if entity_type else []
|
||||
|
||||
for entity in entities:
|
||||
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,
|
||||
schema_def,
|
||||
_entity_observations(entity),
|
||||
_entity_relations(entity),
|
||||
)
|
||||
results.append(_to_note_validation_response(result))
|
||||
|
||||
valid = sum(1 for r in results if r.passed)
|
||||
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),
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
# --- Inference ---
|
||||
|
||||
|
||||
@router.post("/schema/infer", response_model=InferenceReport)
|
||||
async def infer_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_type: str = Query(..., description="Entity type to analyze"),
|
||||
threshold: float = Query(0.25, description="Minimum frequency for optional fields"),
|
||||
):
|
||||
"""Infer a schema from existing notes of a given type.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = infer_schema(entity_type, notes_data, optional_threshold=threshold)
|
||||
|
||||
return InferenceReport(
|
||||
entity_type=result.entity_type,
|
||||
notes_analyzed=result.notes_analyzed,
|
||||
field_frequencies=[
|
||||
FieldFrequencyResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
sample_values=f.sample_values,
|
||||
is_array=f.is_array,
|
||||
target_type=f.target_type,
|
||||
)
|
||||
for f in result.field_frequencies
|
||||
],
|
||||
suggested_schema=result.suggested_schema,
|
||||
suggested_required=result.suggested_required,
|
||||
suggested_optional=result.suggested_optional,
|
||||
excluded=result.excluded,
|
||||
)
|
||||
|
||||
|
||||
# --- Drift Detection ---
|
||||
|
||||
|
||||
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
|
||||
async def diff_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_type: str = Path(..., description="Entity type to check for drift"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Show drift between a schema definition and actual note usage.
|
||||
|
||||
Compares the existing schema for an entity type against how notes
|
||||
of that type are actually structured. Identifies new fields, dropped
|
||||
fields, and cardinality changes.
|
||||
"""
|
||||
|
||||
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, schema_found=False)
|
||||
|
||||
# Collect all notes of this type
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = diff_schema(schema_def, notes_data)
|
||||
|
||||
return DriftReport(
|
||||
entity_type=entity_type,
|
||||
new_fields=[
|
||||
DriftFieldResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
)
|
||||
for f in result.new_fields
|
||||
],
|
||||
dropped_fields=[
|
||||
DriftFieldResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
)
|
||||
for f in result.dropped_fields
|
||||
],
|
||||
cardinality_changes=result.cardinality_changes,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
async def _find_by_entity_type(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_type: str,
|
||||
) -> list[Entity]:
|
||||
"""Find all entities of a given type using the repository's select pattern."""
|
||||
query = entity_repository.select().where(Entity.entity_type == entity_type)
|
||||
result = await entity_repository.execute_query(query)
|
||||
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(
|
||||
note_identifier=result.note_identifier,
|
||||
schema_entity=result.schema_entity,
|
||||
passed=result.passed,
|
||||
field_results=[
|
||||
FieldResultResponse(
|
||||
field_name=fr.field.name,
|
||||
field_type=fr.field.type,
|
||||
required=fr.field.required,
|
||||
status=fr.status,
|
||||
values=fr.values,
|
||||
message=fr.message,
|
||||
)
|
||||
for fr in result.field_results
|
||||
],
|
||||
unmatched_observations=result.unmatched_observations,
|
||||
unmatched_relations=result.unmatched_relations,
|
||||
warnings=result.warnings,
|
||||
errors=result.errors,
|
||||
)
|
||||
@@ -4,13 +4,9 @@ This router uses external_id UUIDs for stable, API-friendly routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
from fastapi import APIRouter, Path
|
||||
|
||||
from basic_memory.api.v2.utils import to_search_results
|
||||
from basic_memory.repository.semantic_errors import (
|
||||
SemanticDependenciesMissingError,
|
||||
SemanticSearchDisabledError,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
@@ -49,14 +45,7 @@ async def search(
|
||||
"""
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
try:
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
except SemanticSearchDisabledError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SemanticDependenciesMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
|
||||
@@ -9,7 +9,6 @@ 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, maybe_show_init_line # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
|
||||
|
||||
@@ -47,31 +46,11 @@ def app_callback(
|
||||
container = CliContainer.create()
|
||||
set_container(container)
|
||||
|
||||
# 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
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
# Skip for 'reset' command - it manages its own database lifecycle
|
||||
skip_init_commands = {
|
||||
"doctor",
|
||||
"mcp",
|
||||
"status",
|
||||
"sync",
|
||||
"project",
|
||||
"tool",
|
||||
"reset",
|
||||
"reindex",
|
||||
"watch",
|
||||
}
|
||||
skip_init_commands = {"doctor", "mcp", "status", "sync", "project", "tool", "reset"}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
"""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,
|
||||
workspace,
|
||||
)
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -24,7 +15,4 @@ __all__ = [
|
||||
"tool",
|
||||
"project",
|
||||
"format",
|
||||
"schema",
|
||||
"watch",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
@@ -6,7 +6,6 @@ from rich.console import Console
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
@@ -30,7 +29,7 @@ console = Console()
|
||||
|
||||
@cloud_app.command()
|
||||
def login():
|
||||
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
|
||||
"""Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
|
||||
|
||||
async def _login():
|
||||
client_id, domain, host_url = get_cloud_config()
|
||||
@@ -46,15 +45,18 @@ def login():
|
||||
console.print("[dim]Verifying subscription access...[/dim]")
|
||||
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
|
||||
|
||||
console.print("[green]Cloud authentication successful[/green]")
|
||||
console.print(f"[dim]Cloud host ready: {host_url}[/dim]")
|
||||
# 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]")
|
||||
|
||||
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"
|
||||
)
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
console.print(
|
||||
"[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
|
||||
@@ -66,52 +68,50 @@ def login():
|
||||
|
||||
@cloud_app.command()
|
||||
def logout():
|
||||
"""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]")
|
||||
"""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]")
|
||||
|
||||
|
||||
@cloud_app.command("status")
|
||||
def status() -> None:
|
||||
"""Check cloud authentication state and cloud instance health."""
|
||||
"""Check cloud mode status and cloud instance health."""
|
||||
# Check cloud mode
|
||||
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 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}")
|
||||
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
|
||||
|
||||
# Get cloud configuration
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
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
|
||||
# Prepare headers
|
||||
headers = {}
|
||||
|
||||
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"))
|
||||
response = run_with_cleanup(
|
||||
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
|
||||
)
|
||||
|
||||
health_data = response.json()
|
||||
|
||||
@@ -128,12 +128,11 @@ def status() -> None:
|
||||
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
|
||||
|
||||
except CloudAPIError as e:
|
||||
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]"
|
||||
)
|
||||
console.print(f"[red]Error checking cloud health: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Unexpected health check error: {e}[/yellow]")
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("setup")
|
||||
@@ -192,93 +191,3 @@ def setup() -> None:
|
||||
except Exception as e:
|
||||
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("promo")
|
||||
def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disable CLI promos.")):
|
||||
"""Enable or disable CLI cloud promo messages."""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_promo_opt_out = not enabled
|
||||
config_manager.save_config(config)
|
||||
|
||||
if enabled:
|
||||
console.print("[green]Cloud promo messages enabled[/green]")
|
||||
else:
|
||||
console.print("[yellow]Cloud promo messages disabled[/yellow]")
|
||||
|
||||
|
||||
@cloud_app.command("set-key")
|
||||
def set_key(
|
||||
api_key: str = typer.Argument(..., help="API key (bmc_ prefixed) for cloud access"),
|
||||
) -> None:
|
||||
"""Save a cloud API key for per-project cloud routing.
|
||||
|
||||
The API key is account-level and used by projects set to cloud mode.
|
||||
Create a key in the web app or use 'bm cloud create-key'.
|
||||
|
||||
Example:
|
||||
bm cloud set-key bmc_abc123...
|
||||
"""
|
||||
if not api_key.startswith("bmc_"):
|
||||
console.print("[red]Error: API key must start with 'bmc_'[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_api_key = api_key
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print("[green]API key saved[/green]")
|
||||
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
|
||||
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
|
||||
|
||||
|
||||
@cloud_app.command("create-key")
|
||||
def create_key(
|
||||
name: str = typer.Argument(..., help="Human-readable name for the API key"),
|
||||
) -> None:
|
||||
"""Create a new cloud API key and save it locally.
|
||||
|
||||
Requires active OAuth session (run 'bm cloud login' first).
|
||||
The key is created via the cloud API and saved to local config.
|
||||
|
||||
Example:
|
||||
bm cloud create-key "my-laptop"
|
||||
"""
|
||||
|
||||
async def _create_key():
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
console.print(f"[dim]Creating API key '{name}'...[/dim]")
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/api/keys",
|
||||
json_data={"name": name},
|
||||
)
|
||||
|
||||
key_data = response.json()
|
||||
api_key = key_data.get("key")
|
||||
if not api_key:
|
||||
console.print("[red]Error: No key returned from API[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Save to config
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_api_key = api_key
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]API key '{name}' created and saved[/green]")
|
||||
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
|
||||
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
|
||||
|
||||
try:
|
||||
run_with_cleanup(_create_key())
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error creating API key: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -28,13 +28,11 @@ console = Console()
|
||||
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
|
||||
|
||||
# Tigris edge caching returns stale data for users outside the origin region (iad).
|
||||
# --header is rclone's global flag that applies to ALL HTTP transactions (list, download,
|
||||
# upload). This is critical because bisync starts with S3 ListObjectsV2, which is neither
|
||||
# a download nor upload — so --header-download/--header-upload would miss list requests.
|
||||
# These headers bypass edge cache and force reads/writes against the origin.
|
||||
# See: https://www.tigrisdata.com/docs/objects/consistency/
|
||||
TIGRIS_CONSISTENCY_HEADERS = [
|
||||
"--header",
|
||||
"X-Tigris-Consistent: true",
|
||||
"--header-download", "X-Tigris-Consistent: true",
|
||||
"--header-upload", "X-Tigris-Consistent: true",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,11 +55,8 @@ 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(project_name=project) as client:
|
||||
async with get_client() as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
url = f"/v2/projects/{project_item.external_id}/sync"
|
||||
params = []
|
||||
@@ -91,8 +88,9 @@ async def run_sync(
|
||||
|
||||
async def get_project_info(project: str):
|
||||
"""Get project information via API endpoint."""
|
||||
|
||||
try:
|
||||
async with get_client(project_name=project) as client:
|
||||
async with get_client() 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())
|
||||
|
||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from basic_memory import db
|
||||
@@ -104,118 +103,3 @@ def reset(
|
||||
# ensures db.shutdown_db() is called even if _reindex_projects changes
|
||||
run_with_cleanup(_reindex_projects(app_config))
|
||||
console.print("[green]Reindex complete[/green]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def reindex(
|
||||
embeddings: bool = typer.Option(
|
||||
False, "--embeddings", "-e", help="Rebuild vector embeddings (requires semantic search)"
|
||||
),
|
||||
search: bool = typer.Option(False, "--search", "-s", help="Rebuild full-text search index"),
|
||||
project: str = typer.Option(
|
||||
None, "--project", "-p", help="Reindex a specific project (default: all)"
|
||||
),
|
||||
): # pragma: no cover
|
||||
"""Rebuild search indexes and/or vector embeddings without dropping the database.
|
||||
|
||||
By default rebuilds everything (search + embeddings if semantic is enabled).
|
||||
Use --search or --embeddings to rebuild only one.
|
||||
|
||||
Examples:
|
||||
bm reindex # Rebuild everything
|
||||
bm reindex --embeddings # Only rebuild vector embeddings
|
||||
bm reindex --search # Only rebuild FTS index
|
||||
bm reindex -p claw # Reindex only the 'claw' project
|
||||
"""
|
||||
# If neither flag is set, do both
|
||||
if not embeddings and not search:
|
||||
embeddings = True
|
||||
search = True
|
||||
|
||||
config_manager = ConfigManager()
|
||||
app_config = config_manager.config
|
||||
|
||||
if embeddings and not app_config.semantic_search_enabled:
|
||||
console.print(
|
||||
"[yellow]Semantic search is not enabled.[/yellow] "
|
||||
"Set [cyan]semantic_search_enabled: true[/cyan] in config to use embeddings."
|
||||
)
|
||||
embeddings = False
|
||||
if not search:
|
||||
raise typer.Exit(0)
|
||||
|
||||
run_with_cleanup(_reindex(app_config, search=search, embeddings=embeddings, project=project))
|
||||
|
||||
|
||||
async def _reindex(app_config, search: bool, embeddings: bool, project: str | None):
|
||||
"""Run reindex operations."""
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import create_search_repository
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
|
||||
try:
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
projects = await project_repository.get_active_projects()
|
||||
|
||||
if project:
|
||||
projects = [p for p in projects if p.name == project]
|
||||
if not projects:
|
||||
console.print(f"[red]Project '{project}' not found.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
for proj in projects:
|
||||
console.print(f"\n[bold]Project: [cyan]{proj.name}[/cyan][/bold]")
|
||||
|
||||
if search:
|
||||
console.print(" Rebuilding full-text search index...")
|
||||
sync_service = await get_sync_service(proj)
|
||||
sync_dir = Path(proj.path)
|
||||
await sync_service.sync(sync_dir, project_name=proj.name)
|
||||
console.print(" [green]✓[/green] Full-text search index rebuilt")
|
||||
|
||||
if embeddings:
|
||||
console.print(" Building vector embeddings...")
|
||||
entity_repository = EntityRepository(session_maker, project_id=proj.id)
|
||||
search_repository = create_search_repository(
|
||||
session_maker, project_id=proj.id, app_config=app_config
|
||||
)
|
||||
project_path = Path(proj.path)
|
||||
entity_parser = EntityParser(project_path)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(project_path, markdown_processor, app_config=app_config)
|
||||
search_service = SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task(" Embedding entities...", total=None)
|
||||
|
||||
def on_progress(entity_id, index, total):
|
||||
progress.update(task, total=total, completed=index)
|
||||
|
||||
stats = await search_service.reindex_vectors(progress_callback=on_progress)
|
||||
progress.update(task, completed=stats["total_entities"])
|
||||
|
||||
console.print(
|
||||
f" [green]✓[/green] Embeddings complete: "
|
||||
f"{stats['embedded']} entities embedded, "
|
||||
f"{stats['skipped']} skipped, "
|
||||
f"{stats['errors']} errors"
|
||||
)
|
||||
|
||||
console.print("\n[green]Reindex complete![/green]")
|
||||
finally:
|
||||
await db.shutdown_db()
|
||||
|
||||
@@ -60,9 +60,7 @@ 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, project_name=config.name
|
||||
)
|
||||
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
|
||||
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,9 +57,7 @@ def import_claude(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeConversationsImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
|
||||
@@ -56,9 +56,7 @@ def import_projects(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeProjectsImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
|
||||
@@ -55,9 +55,7 @@ def memory_json(
|
||||
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = MemoryJsonImporter(
|
||||
config.home, markdown_processor, file_service, project_name=config.name
|
||||
)
|
||||
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home if not destination_folder else config.home / destination_folder
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
"""MCP server command with streamable HTTP transport."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, init_mcp_logging
|
||||
|
||||
# Import mcp instance (has lifespan that handles initialization and file sync)
|
||||
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
|
||||
class _DeferredMcpServer:
|
||||
def run(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
||||
from basic_memory.mcp.server import mcp as live_mcp_server
|
||||
# Import mcp tools to register them
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
|
||||
live_mcp_server.run(*args, **kwargs)
|
||||
|
||||
|
||||
# Keep module-level attribute for tests/monkeypatching while deferring heavy import.
|
||||
mcp_server = _DeferredMcpServer()
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -45,19 +42,10 @@ 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.
|
||||
# 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.
|
||||
# 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.
|
||||
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
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
|
||||
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
|
||||
@@ -11,13 +11,12 @@ 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, ProjectEntry, ProjectMode
|
||||
from basic_memory.config import ConfigManager
|
||||
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 ProjectItem, ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
@@ -47,31 +46,18 @@ 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 routing for this command"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
) -> None:
|
||||
"""List Basic Memory projects from local and (when available) cloud."""
|
||||
"""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.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
@@ -84,112 +70,45 @@ 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("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")
|
||||
table.add_column("Path", style="green")
|
||||
|
||||
project_names_by_permalink: dict[str, str] = {}
|
||||
local_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
cloud_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
# 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")
|
||||
|
||||
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
|
||||
# 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 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
|
||||
for project in result.projects:
|
||||
is_default = "[X]" if project.is_default else ""
|
||||
normalized_path = normalize_project_path(project.path)
|
||||
|
||||
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)
|
||||
# Build row based on mode
|
||||
row = [project.name, format_path(normalized_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 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)
|
||||
|
||||
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,
|
||||
]
|
||||
# Add default indicator if showing default column
|
||||
if show_default_column:
|
||||
row.append(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)
|
||||
@@ -230,8 +149,8 @@ def add_project(
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
# Determine effective mode: default local, cloud only when explicitly requested.
|
||||
effective_cloud_mode = cloud and not local
|
||||
# Determine effective mode: local flag forces local mode behavior
|
||||
effective_cloud_mode = config.cloud_mode_enabled and not local
|
||||
|
||||
# Resolve local sync path early (needed for both cloud and local mode)
|
||||
local_sync_path: str | None = None
|
||||
@@ -239,7 +158,6 @@ 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():
|
||||
@@ -274,20 +192,18 @@ 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 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,
|
||||
)
|
||||
# Update config with sync path
|
||||
config.cloud_projects[name] = CloudProjectConfig(
|
||||
local_path=local_sync_path,
|
||||
last_sync=None,
|
||||
bisync_initialized=False,
|
||||
)
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
|
||||
@@ -311,7 +227,10 @@ def setup_project_sync(
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: sync-setup only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
@@ -325,24 +244,20 @@ def setup_project_sync(
|
||||
|
||||
try:
|
||||
# Verify project exists on cloud
|
||||
with force_routing(cloud=True):
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
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 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(),
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Sync configured for project '{name}'[/green]")
|
||||
@@ -399,9 +314,8 @@ def remove_project(
|
||||
local_path_config = None
|
||||
has_bisync_state = False
|
||||
|
||||
entry = config.projects.get(name)
|
||||
if cloud and entry and entry.cloud_sync_path:
|
||||
local_path_config = entry.cloud_sync_path
|
||||
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
|
||||
local_path_config = config.cloud_projects[name].local_path
|
||||
|
||||
# Check for bisync state
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
|
||||
@@ -433,11 +347,9 @@ def remove_project(
|
||||
shutil.rmtree(bisync_state_path)
|
||||
console.print("[green]Removed bisync state[/green]")
|
||||
|
||||
# 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
|
||||
# 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]
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Show informative message if files were not deleted
|
||||
@@ -457,10 +369,21 @@ def set_default_project(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Set the default project used as fallback when no project is specified.
|
||||
"""Set the default project when 'config.default_project_mode' is set.
|
||||
|
||||
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:
|
||||
@@ -497,6 +420,17 @@ 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:
|
||||
@@ -524,6 +458,18 @@ 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()
|
||||
|
||||
@@ -565,74 +511,6 @@ def move_project(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("set-cloud")
|
||||
def set_cloud(
|
||||
name: str = typer.Argument(..., help="Name of the project to route through cloud"),
|
||||
) -> None:
|
||||
"""Set a project to cloud mode (route through cloud API).
|
||||
|
||||
Requires either an API key or an active OAuth session.
|
||||
|
||||
Examples:
|
||||
bm cloud set-key bmc_abc123... # save API key, then:
|
||||
bm project set-cloud research # route "research" through cloud
|
||||
|
||||
bm cloud login # OAuth login, then:
|
||||
bm project set-cloud research # route "research" through cloud
|
||||
"""
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in config.projects:
|
||||
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate credentials: API key or OAuth session
|
||||
has_api_key = bool(config.cloud_api_key)
|
||||
has_oauth = False
|
||||
if not has_api_key:
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
has_oauth = auth.load_tokens() is not None
|
||||
|
||||
if not has_api_key and not has_oauth:
|
||||
console.print("[red]Error: No cloud credentials found[/red]")
|
||||
console.print("[dim]Run 'bm cloud set-key <key>' or 'bm cloud login' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.set_project_mode(name, ProjectMode.CLOUD)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Project '{name}' set to cloud mode[/green]")
|
||||
console.print("[dim]MCP tools and CLI commands for this project will route through cloud[/dim]")
|
||||
|
||||
|
||||
@project_app.command("set-local")
|
||||
def set_local(
|
||||
name: str = typer.Argument(..., help="Name of the project to revert to local mode"),
|
||||
) -> None:
|
||||
"""Revert a project to local mode (use in-process ASGI transport).
|
||||
|
||||
Example:
|
||||
bm project set-local research
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in config.projects:
|
||||
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.set_project_mode(name, ProjectMode.LOCAL)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Project '{name}' set to local mode[/green]")
|
||||
console.print("[dim]MCP tools and CLI commands for this project will use local transport[/dim]")
|
||||
|
||||
|
||||
@project_app.command("sync")
|
||||
def sync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to sync"),
|
||||
@@ -646,7 +524,9 @@ def sync_project_command(
|
||||
bm project sync --name research --dry-run
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: sync only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -663,15 +543,15 @@ def sync_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
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 project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
# 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
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -705,8 +585,7 @@ def sync_project_command(
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
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]")
|
||||
@@ -737,7 +616,9 @@ def bisync_project_command(
|
||||
bm project bisync --name research --dry-run # Preview changes
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: bisync only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -754,15 +635,15 @@ def bisync_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
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 project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
# 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
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -785,11 +666,9 @@ def bisync_project_command(
|
||||
if success:
|
||||
console.print(f"[green]{name} bisync completed successfully[/green]")
|
||||
|
||||
# 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
|
||||
# Update config
|
||||
config.cloud_projects[name].last_sync = datetime.now()
|
||||
config.cloud_projects[name].bisync_initialized = True
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
@@ -805,8 +684,7 @@ def bisync_project_command(
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
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]")
|
||||
@@ -833,7 +711,9 @@ def check_project_command(
|
||||
bm project check --name research
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: check only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
@@ -850,15 +730,15 @@ def check_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
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 project entry
|
||||
check_entry = config.projects.get(name)
|
||||
local_sync_path = check_entry.cloud_sync_path if check_entry else None
|
||||
# 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
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
@@ -924,52 +804,23 @@ 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 a project.
|
||||
"""List files in remote 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
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
config = ConfigManager().config
|
||||
if not config.cloud_mode_enabled:
|
||||
console.print("[red]Error: ls only available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
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 tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
@@ -980,46 +831,29 @@ def ls_project_command(
|
||||
return proj
|
||||
return None
|
||||
|
||||
if use_cloud_route:
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
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)
|
||||
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
# Create SyncProject (local_sync_path not needed for ls)
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.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"
|
||||
# List files
|
||||
files = project_ls(sync_project, bucket_name, path=path)
|
||||
|
||||
if files:
|
||||
heading = f"\n[bold]Files in {name} ({target_label})"
|
||||
if path:
|
||||
heading += f"/{path}"
|
||||
heading += ":[/bold]"
|
||||
console.print(heading)
|
||||
console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]")
|
||||
for file in files:
|
||||
console.print(f" {file}")
|
||||
console.print(f"\n[dim]Total: {len(files)} files[/dim]")
|
||||
else:
|
||||
prefix = f"[yellow]No files found in {name} ({target_label})"
|
||||
console.print(prefix + (f"/{path}" if path else "") + "[/yellow]")
|
||||
console.print(
|
||||
f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""CLI routing utilities for --local/--cloud flag handling.
|
||||
|
||||
This module provides utilities for CLI commands to override default routing.
|
||||
This allows users to force local or cloud routing per-command.
|
||||
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)
|
||||
|
||||
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()
|
||||
"""
|
||||
|
||||
@@ -23,14 +24,9 @@ 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
|
||||
cloud: If True, force cloud proxy transport
|
||||
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
|
||||
cloud: If True, clear force_local to allow cloud routing
|
||||
|
||||
Usage:
|
||||
with force_routing(local=True):
|
||||
@@ -45,37 +41,23 @@ 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 values
|
||||
# Restore original value
|
||||
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.
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
"""Schema management CLI commands for Basic Memory.
|
||||
|
||||
Provides CLI access to schema validation, inference, and drift detection.
|
||||
Registered as a subcommand group: `bm schema validate`, `bm schema infer`, `bm schema diff`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
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.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.project_context import get_active_project
|
||||
|
||||
console = Console()
|
||||
|
||||
schema_app = typer.Typer(help="Schema management commands")
|
||||
app.add_typer(schema_app, name="schema")
|
||||
|
||||
|
||||
def _resolve_project_name(project: Optional[str]) -> Optional[str]:
|
||||
"""Resolve project name from CLI argument or config default."""
|
||||
config_manager = ConfigManager()
|
||||
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)
|
||||
return project_name
|
||||
return config_manager.default_project
|
||||
|
||||
|
||||
# --- Validate ---
|
||||
|
||||
|
||||
async def _run_validate(
|
||||
target: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
strict: bool = False,
|
||||
):
|
||||
"""Run schema validation via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
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 note type
|
||||
# Heuristic: if target contains / or ., treat as identifier
|
||||
entity_type = None
|
||||
identifier = None
|
||||
if target:
|
||||
if "/" in target or "." in target:
|
||||
identifier = target
|
||||
else:
|
||||
entity_type = target
|
||||
|
||||
report = await schema_client.validate(
|
||||
entity_type=entity_type,
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
# --- Display results ---
|
||||
if report.total_notes == 0:
|
||||
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'}")
|
||||
table.add_column("Note", style="cyan")
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Warnings", justify="right")
|
||||
table.add_column("Errors", justify="right")
|
||||
|
||||
for result in report.results:
|
||||
if result.passed and not result.warnings:
|
||||
status = "[green]pass[/green]"
|
||||
elif result.passed:
|
||||
status = "[yellow]warn[/yellow]"
|
||||
else:
|
||||
status = "[red]fail[/red]"
|
||||
|
||||
table.add_row(
|
||||
result.note_identifier,
|
||||
status,
|
||||
str(len(result.warnings)),
|
||||
str(len(result.errors)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(
|
||||
f"\nSummary: {report.valid_count}/{report.total_notes} valid, "
|
||||
f"{report.warning_count} warnings, {report.error_count} errors"
|
||||
)
|
||||
|
||||
# Exit with error code in strict mode if there are failures
|
||||
if strict and report.error_count > 0:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
def validate(
|
||||
target: Annotated[
|
||||
Optional[str],
|
||||
typer.Argument(help="Note path or note type to validate"),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
strict: bool = typer.Option(False, "--strict", help="Exit with error on validation failures"),
|
||||
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"),
|
||||
):
|
||||
"""Validate notes against their 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.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
project_name = _resolve_project_name(project)
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_validate(target, project_name, strict))
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.error(f"Error during schema validate: {e}")
|
||||
typer.echo(f"Error during schema validate: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
# --- Infer ---
|
||||
|
||||
|
||||
async def _run_infer(
|
||||
entity_type: str,
|
||||
project: Optional[str] = None,
|
||||
threshold: float = 0.25,
|
||||
save: bool = False,
|
||||
):
|
||||
"""Run schema inference via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
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)
|
||||
|
||||
report = await schema_client.infer(entity_type, threshold=threshold)
|
||||
|
||||
if report.notes_analyzed == 0:
|
||||
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"
|
||||
)
|
||||
|
||||
table = Table(title="Field Frequencies")
|
||||
table.add_column("Field", style="cyan")
|
||||
table.add_column("Source")
|
||||
table.add_column("Count", justify="right")
|
||||
table.add_column("Percentage", justify="right")
|
||||
table.add_column("Suggested")
|
||||
|
||||
for freq in report.field_frequencies:
|
||||
pct = f"{freq.percentage:.0%}"
|
||||
if freq.name in report.suggested_required:
|
||||
suggested = "[green]required[/green]"
|
||||
elif freq.name in report.suggested_optional:
|
||||
suggested = "[yellow]optional[/yellow]"
|
||||
else:
|
||||
suggested = "[dim]excluded[/dim]"
|
||||
|
||||
table.add_row(
|
||||
freq.name,
|
||||
freq.source,
|
||||
str(freq.count),
|
||||
pct,
|
||||
suggested,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# --- Display suggested schema ---
|
||||
console.print("\n[bold]Suggested schema:[/bold]")
|
||||
console.print(json.dumps(report.suggested_schema, indent=2))
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
f"\n[yellow]--save not yet implemented. "
|
||||
f"Copy the schema above into schema/{entity_type}.md[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
def infer(
|
||||
entity_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
|
||||
],
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
threshold: float = typer.Option(
|
||||
0.25, "--threshold", help="Minimum frequency for optional fields (0-1)"
|
||||
),
|
||||
save: bool = typer.Option(False, "--save", help="Save inferred schema to schema/ directory"),
|
||||
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"),
|
||||
):
|
||||
"""Infer schema from existing notes of a type.
|
||||
|
||||
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
|
||||
threshold (default 25%) become optional. Fields below threshold are excluded.
|
||||
|
||||
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)
|
||||
project_name = _resolve_project_name(project)
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_infer(entity_type, project_name, threshold, save))
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.error(f"Error during schema infer: {e}")
|
||||
typer.echo(f"Error during schema infer: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
# --- Diff ---
|
||||
|
||||
|
||||
async def _run_diff(
|
||||
entity_type: str,
|
||||
project: Optional[str] = None,
|
||||
):
|
||||
"""Run schema drift detection via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
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)
|
||||
|
||||
report = await schema_client.diff(entity_type)
|
||||
|
||||
has_drift = report.new_fields or report.dropped_fields or report.cardinality_changes
|
||||
|
||||
if not has_drift:
|
||||
console.print(f"[green]No drift detected for {entity_type} schema.[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Schema drift detected for {entity_type}:[/bold]\n")
|
||||
|
||||
if report.new_fields:
|
||||
console.print("[green]+ New fields (common in notes, not in schema):[/green]")
|
||||
for f in report.new_fields:
|
||||
console.print(f" + {f.name}: {f.percentage:.0%} of notes ({f.source})")
|
||||
|
||||
if report.dropped_fields:
|
||||
console.print("[red]- Dropped fields (in schema, rare in notes):[/red]")
|
||||
for f in report.dropped_fields:
|
||||
console.print(f" - {f.name}: {f.percentage:.0%} of notes ({f.source})")
|
||||
|
||||
if report.cardinality_changes:
|
||||
console.print("[yellow]~ Cardinality changes:[/yellow]")
|
||||
for change in report.cardinality_changes:
|
||||
console.print(f" ~ {change}")
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
def diff(
|
||||
entity_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to check for drift"),
|
||||
],
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Show drift between schema and actual usage.
|
||||
|
||||
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.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
project_name = _resolve_project_name(project)
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_diff(entity_type, project_name))
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.error(f"Error during schema diff: {e}")
|
||||
typer.echo(f"Error during schema diff: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -143,11 +142,9 @@ 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(project_name=project) as client:
|
||||
async with get_client() 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,10 +2,9 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Annotated, Any, List, Optional
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from rich import print as rprint
|
||||
|
||||
@@ -13,8 +12,9 @@ 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_project_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
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,8 +24,10 @@ 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
|
||||
@@ -34,60 +36,6 @@ 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)
|
||||
@@ -95,26 +43,15 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None
|
||||
|
||||
|
||||
async def _write_note_json(
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
project_name: Optional[str],
|
||||
workspace: Optional[str],
|
||||
tags: Optional[List[str]],
|
||||
title: str, content: str, folder: str, project_name: 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=title,
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
tags=tags,
|
||||
)
|
||||
await mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
|
||||
# Resolve the entity to get metadata back
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
entity = Entity(title=title, directory=folder)
|
||||
@@ -132,14 +69,11 @@ async def _write_note_json(
|
||||
|
||||
|
||||
async def _read_note_json(
|
||||
identifier: str,
|
||||
project_name: Optional[str],
|
||||
workspace: Optional[str],
|
||||
page: int,
|
||||
page_size: int,
|
||||
identifier: str, project_name: Optional[str], page: int, page_size: int
|
||||
) -> dict:
|
||||
"""Read a note and return structured JSON with content and metadata."""
|
||||
async with get_project_client(project_name, workspace) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
@@ -156,18 +90,12 @@ 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,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
query=identifier, search_type="title", project=project_name
|
||||
)
|
||||
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 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)
|
||||
|
||||
if entity_id is None:
|
||||
raise ValueError(f"Could not find note matching: {identifier}")
|
||||
@@ -183,72 +111,16 @@ 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_project_client(project_name, workspace) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
# Build query params matching the MCP tool's logic
|
||||
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
|
||||
if depth:
|
||||
@@ -258,6 +130,7 @@ 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",
|
||||
@@ -291,10 +164,6 @@ 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(
|
||||
@@ -382,19 +251,12 @@ 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, workspace, tags)
|
||||
_write_note_json(title, content, folder, project_name, tags)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
note = run_with_cleanup(
|
||||
mcp_write_note.fn(
|
||||
title=title,
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
tags=tags,
|
||||
)
|
||||
mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
)
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
@@ -416,21 +278,9 @@ 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)"
|
||||
),
|
||||
@@ -440,7 +290,6 @@ 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)
|
||||
@@ -460,25 +309,11 @@ def read_note(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_read_note_json(identifier, project_name, workspace, page, page_size)
|
||||
_read_note_json(identifier, project_name, 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=identifier,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
if strip_frontmatter:
|
||||
note, _ = _parse_opening_frontmatter(note)
|
||||
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
@@ -490,101 +325,6 @@ 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,
|
||||
@@ -592,10 +332,6 @@ 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,
|
||||
@@ -628,23 +364,19 @@ def build_context(
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
context = 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",
|
||||
)
|
||||
)
|
||||
if format == "json":
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
print(result)
|
||||
context_dict = context.model_dump(exclude_none=True)
|
||||
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -662,10 +394,6 @@ 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)"),
|
||||
@@ -699,15 +427,7 @@ def recent_activity(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_recent_activity_json(
|
||||
type=type,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
project_name=project_name,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
_recent_activity_json(type, depth, timeframe, project_name, page, page_size)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
@@ -717,7 +437,6 @@ def recent_activity(
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
# The tool returns a formatted string directly
|
||||
@@ -740,18 +459,12 @@ def search_notes(
|
||||
] = "",
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
vector: Annotated[bool, typer.Option("--vector", help="Use vector retrieval")] = False,
|
||||
hybrid: Annotated[bool, typer.Option("--hybrid", help="Use hybrid retrieval")] = False,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
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'"),
|
||||
@@ -768,13 +481,6 @@ def search_notes(
|
||||
Optional[List[str]],
|
||||
typer.Option("--type", help="Filter by frontmatter type (repeatable)"),
|
||||
] = None,
|
||||
entity_types: Annotated[
|
||||
Optional[List[str]],
|
||||
typer.Option(
|
||||
"--entity-type",
|
||||
help="Filter by search item type: entity, observation, relation (repeatable)",
|
||||
),
|
||||
] = None,
|
||||
meta: Annotated[
|
||||
Optional[List[str]],
|
||||
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
|
||||
@@ -810,10 +516,9 @@ def search_notes(
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
mode_flags = [permalink, title, vector, hybrid]
|
||||
if sum(1 for enabled in mode_flags if enabled) > 1: # pragma: no cover
|
||||
if permalink and title: # pragma: no cover
|
||||
typer.echo(
|
||||
"Use only one mode flag: --permalink, --title, --vector, or --hybrid. Exiting.",
|
||||
"Use either --permalink or --title, not both. Exiting.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
@@ -855,32 +560,22 @@ def search_notes(
|
||||
search_type = "permalink"
|
||||
if title:
|
||||
search_type = "title"
|
||||
if vector:
|
||||
search_type = "vector"
|
||||
if hybrid:
|
||||
search_type = "hybrid"
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
results = run_with_cleanup(
|
||||
mcp_search.fn(
|
||||
query=query or "",
|
||||
project=project_name,
|
||||
workspace=workspace,
|
||||
query or "",
|
||||
project_name,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
types=note_types,
|
||||
entity_types=entity_types,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
if isinstance(results, str):
|
||||
print(results)
|
||||
raise typer.Exit(1)
|
||||
|
||||
results_dict = results.model_dump(exclude_none=True)
|
||||
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
@@ -928,3 +623,22 @@ 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
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Watch command - run file watcher as a standalone long-running process."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.container import get_container
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.sync.coordinator import SyncCoordinator
|
||||
|
||||
|
||||
async def run_watch(project: Optional[str] = None) -> None:
|
||||
"""Run the file watcher as a long-running process.
|
||||
|
||||
This is the async core of the watch command. It:
|
||||
1. Initializes the app (DB migrations + project reconciliation)
|
||||
2. Validates and sets project constraint if --project given
|
||||
3. Creates a SyncCoordinator with quiet=False for Rich console output
|
||||
4. Blocks until SIGINT/SIGTERM, then shuts down cleanly
|
||||
"""
|
||||
container = get_container()
|
||||
config = container.config
|
||||
|
||||
# --- Initialization ---
|
||||
# Wrapped in try/finally so DB resources are cleaned up on all exit paths,
|
||||
# including early exits from invalid --project names.
|
||||
await initialize_app(config)
|
||||
sync_coordinator = None
|
||||
|
||||
try:
|
||||
# --- Project constraint ---
|
||||
if project:
|
||||
config_manager = ConfigManager()
|
||||
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)
|
||||
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"Watch constrained to project: {project_name}")
|
||||
|
||||
# --- Sync coordinator ---
|
||||
# quiet=False so file change events are printed to the terminal
|
||||
sync_coordinator = SyncCoordinator(config=config, should_sync=True, quiet=False)
|
||||
|
||||
# --- Signal handling ---
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _signal_handler() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Windows ProactorEventLoop does not support add_signal_handler;
|
||||
# fall back to the stdlib signal module which works cross-platform.
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
except NotImplementedError:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, lambda _signum, _frame: _signal_handler())
|
||||
|
||||
# --- Run ---
|
||||
await sync_coordinator.start()
|
||||
logger.info("Watch service running, press Ctrl+C to stop")
|
||||
await shutdown_event.wait()
|
||||
finally:
|
||||
if sync_coordinator is not None:
|
||||
await sync_coordinator.stop()
|
||||
await db.shutdown_db()
|
||||
logger.info("Watch service stopped")
|
||||
|
||||
|
||||
@app.command()
|
||||
def watch(
|
||||
project: Optional[str] = typer.Option(None, help="Restrict watcher to a single project"),
|
||||
) -> None:
|
||||
"""Run file watcher as a long-running process (no MCP server).
|
||||
|
||||
Watches for file changes in project directories and syncs them to the
|
||||
database. Useful for running Basic Memory sync alongside external tools
|
||||
that don't use the MCP server.
|
||||
"""
|
||||
# On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
asyncio.run(run_watch(project=project))
|
||||
@@ -1,57 +0,0 @@
|
||||
"""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,6 +35,7 @@ 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)
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
"""Main CLI entry point for basic-memory.""" # pragma: no cover
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
cloud,
|
||||
db,
|
||||
doctor,
|
||||
import_chatgpt,
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
tool,
|
||||
)
|
||||
|
||||
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
|
||||
# Outcome: eager version callback exits quickly with minimal startup work
|
||||
return len(argv) == 1 and argv[0] in {"--version", "-v"}
|
||||
|
||||
|
||||
if not _version_only_invocation(sys.argv[1:]):
|
||||
# Register commands only when not short-circuiting for --version
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
cloud,
|
||||
db,
|
||||
doctor,
|
||||
import_chatgpt,
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
project,
|
||||
schema,
|
||||
status,
|
||||
tool,
|
||||
workspace,
|
||||
)
|
||||
# Re-apply warning filter AFTER all imports
|
||||
# (authlib adds a DeprecationWarning filter that overrides ours)
|
||||
import warnings # pragma: no cover
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Cloud promo messaging for CLI entrypoint."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
OSS_DISCOUNT_CODE = "BMFOSS"
|
||||
CLOUD_LEARN_MORE_URL = "https://basicmemory.com"
|
||||
|
||||
|
||||
def _promos_disabled_by_env() -> bool:
|
||||
"""Check environment-level kill switch for promo output."""
|
||||
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
|
||||
return value in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _is_interactive_session() -> bool:
|
||||
"""Return whether stdin/stdout are interactive terminals."""
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
|
||||
def _build_cloud_promo_message() -> str:
|
||||
"""Build benefit-led cloud upsell copy with Rich markup."""
|
||||
return (
|
||||
"☁️ [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 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(
|
||||
invoked_subcommand: str | None,
|
||||
*,
|
||||
config_manager: ConfigManager | None = None,
|
||||
is_interactive: bool | None = None,
|
||||
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
|
||||
|
||||
# Trigger: environment-level promo suppression or non-interactive execution.
|
||||
# Why: avoid polluting scripts/CI output and support a hard opt-out.
|
||||
# Outcome: skip all promo copy for this invocation.
|
||||
if _promos_disabled_by_env() or not interactive:
|
||||
return
|
||||
|
||||
# Trigger: command context where cloud promo is not actionable.
|
||||
# Why: mcp/stdin protocol and root help flows should stay noise-free.
|
||||
# Outcome: command continues without promo messaging.
|
||||
if invoked_subcommand in {None, "mcp"}:
|
||||
return
|
||||
|
||||
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 != basic_memory.__version__
|
||||
if not show_first_run and not show_version_notice:
|
||||
return
|
||||
|
||||
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 = basic_memory.__version__
|
||||
manager.save_config(config)
|
||||
+48
-264
@@ -1,6 +1,5 @@
|
||||
"""Configuration management for basic-memory."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@@ -25,13 +24,6 @@ WATCH_STATUS_JSON = "watch-status.json"
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class ProjectMode(str, Enum):
|
||||
"""Per-project routing mode."""
|
||||
|
||||
LOCAL = "local"
|
||||
CLOUD = "cloud"
|
||||
|
||||
|
||||
class DatabaseBackend(str, Enum):
|
||||
"""Supported database backends."""
|
||||
|
||||
@@ -39,18 +31,12 @@ 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."""
|
||||
|
||||
name: str
|
||||
home: Path
|
||||
mode: ProjectMode = ProjectMode.LOCAL
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
@@ -66,9 +52,6 @@ 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")
|
||||
@@ -80,52 +63,26 @@ 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, ProjectEntry] = Field(
|
||||
projects: Dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
"main": ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
)
|
||||
"main": 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 ProjectEntry configuration",
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
)
|
||||
default_project: Optional[str] = Field(
|
||||
default_project: str = Field(
|
||||
default="main",
|
||||
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.",
|
||||
description="Name of the default project to use",
|
||||
)
|
||||
default_project_mode: bool = Field(
|
||||
default=False,
|
||||
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
|
||||
)
|
||||
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
@@ -142,40 +99,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
|
||||
)
|
||||
|
||||
# Semantic search configuration
|
||||
semantic_search_enabled: bool = Field(
|
||||
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(
|
||||
default="fastembed",
|
||||
description="Embedding provider for local semantic indexing/search.",
|
||||
)
|
||||
semantic_embedding_model: str = Field(
|
||||
default="bge-small-en-v1.5",
|
||||
description="Embedding model identifier used by the local provider.",
|
||||
)
|
||||
semantic_embedding_dimensions: int | None = Field(
|
||||
default=None,
|
||||
description="Embedding vector dimensions. Auto-detected from provider if not set (384 for FastEmbed, 1536 for OpenAI).",
|
||||
)
|
||||
semantic_embedding_batch_size: int = Field(
|
||||
default=64,
|
||||
description="Batch size for embedding generation.",
|
||||
gt=0,
|
||||
)
|
||||
semantic_vector_k: int = Field(
|
||||
default=100,
|
||||
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(
|
||||
default=20,
|
||||
@@ -237,16 +160,6 @@ 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.",
|
||||
@@ -298,99 +211,16 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Basic Memory Cloud host URL",
|
||||
)
|
||||
|
||||
cloud_promo_opt_out: bool = Field(
|
||||
cloud_mode: bool = Field(
|
||||
default=False,
|
||||
description="Disable CLI cloud promo messages when true.",
|
||||
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
||||
)
|
||||
|
||||
cloud_promo_first_run_shown: bool = Field(
|
||||
default=False,
|
||||
description="Tracks whether the first-run cloud promo message has been shown.",
|
||||
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_last_version_shown: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Most recent cloud promo version shown in CLI.",
|
||||
)
|
||||
|
||||
cloud_api_key: Optional[str] = Field(
|
||||
default=None,
|
||||
description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.",
|
||||
)
|
||||
|
||||
@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:
|
||||
"""Check if running in a test environment.
|
||||
@@ -408,31 +238,27 @@ class BasicMemoryConfig(BaseSettings):
|
||||
or os.getenv("PYTEST_CURRENT_TEST") is not None
|
||||
)
|
||||
|
||||
def get_project_mode(self, project_name: str) -> ProjectMode:
|
||||
"""Get the routing mode for a project.
|
||||
@property
|
||||
def cloud_mode_enabled(self) -> bool:
|
||||
"""Check if cloud mode is enabled.
|
||||
|
||||
Returns the per-project mode if set, otherwise LOCAL.
|
||||
Priority:
|
||||
1. BASIC_MEMORY_CLOUD_MODE environment variable
|
||||
2. Config file value (cloud_mode)
|
||||
"""
|
||||
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.
|
||||
|
||||
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.projects[project_name] = ProjectEntry(path="", mode=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
|
||||
|
||||
@classmethod
|
||||
def for_cloud_tenant(
|
||||
cls,
|
||||
database_url: str,
|
||||
projects: Optional[Dict[str, "ProjectEntry"]] = None,
|
||||
projects: Optional[Dict[str, str]] = None,
|
||||
) -> "BasicMemoryConfig":
|
||||
"""Create config for cloud tenant - no config.json, database is source of truth.
|
||||
|
||||
@@ -454,6 +280,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
database_backend=DatabaseBackend.POSTGRES,
|
||||
database_url=database_url,
|
||||
projects=projects or {},
|
||||
cloud_mode=True,
|
||||
skip_initialization_sync=True,
|
||||
)
|
||||
|
||||
@@ -469,7 +296,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if name not in self.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
return Path(self.projects[name].path)
|
||||
return Path(self.projects[name])
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
@@ -479,15 +306,12 @@ 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"] = ProjectEntry(
|
||||
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
)
|
||||
|
||||
# Ensure default project is valid (i.e. points to an existing project)
|
||||
# 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
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
# Set default to first available project
|
||||
self.default_project = next(iter(self.projects.keys()))
|
||||
|
||||
@@ -497,11 +321,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
This is the single database that will store all knowledge data
|
||||
across all projects.
|
||||
|
||||
Uses BASIC_MEMORY_CONFIG_DIR when set so each process/worktree can
|
||||
isolate both config and database state.
|
||||
"""
|
||||
database_path = self.data_dir_path / APP_DATABASE_NAME
|
||||
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
|
||||
if not database_path.exists(): # pragma: no cover
|
||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
database_path.touch()
|
||||
@@ -523,10 +344,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
@property
|
||||
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
|
||||
"""Get all configured projects as ProjectConfig objects."""
|
||||
return [
|
||||
ProjectConfig(name=name, home=Path(entry.path), mode=entry.mode)
|
||||
for name, entry in self.projects.items()
|
||||
]
|
||||
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
|
||||
@@ -539,8 +357,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return self
|
||||
|
||||
for name, entry in self.projects.items():
|
||||
path = Path(entry.path)
|
||||
for name, path_value in self.projects.items():
|
||||
path = Path(path_value)
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
@@ -550,13 +368,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
return self
|
||||
|
||||
@property
|
||||
def data_dir_path(self) -> Path:
|
||||
"""Get app state directory for config and default SQLite database."""
|
||||
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
||||
return Path(config_dir)
|
||||
|
||||
home = os.getenv("HOME", Path.home())
|
||||
return Path(home) / DATA_DIR_NAME
|
||||
def data_dir_path(self):
|
||||
return Path.home() / DATA_DIR_NAME
|
||||
|
||||
|
||||
# Module-level cache for configuration
|
||||
@@ -606,22 +419,6 @@ 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
|
||||
@@ -643,12 +440,6 @@ 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}")
|
||||
@@ -667,15 +458,11 @@ class ConfigManager:
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
"""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()}
|
||||
"""Get all configured projects."""
|
||||
return self.config.projects.copy()
|
||||
|
||||
@property
|
||||
def default_project(self) -> Optional[str]:
|
||||
def default_project(self) -> str:
|
||||
"""Get the default project name."""
|
||||
return self.config.default_project
|
||||
|
||||
@@ -691,7 +478,7 @@ class ConfigManager:
|
||||
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = ProjectEntry(path=str(project_path))
|
||||
config.projects[name] = str(project_path)
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
@@ -723,15 +510,12 @@ 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.
|
||||
|
||||
Returns (project_name, path_string) for backward compatibility.
|
||||
"""
|
||||
"""Look up a project from the configuration by name or permalink"""
|
||||
project_permalink = generate_permalink(name)
|
||||
app_config = self.config
|
||||
for project_name, entry in app_config.projects.items():
|
||||
for project_name, path in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(project_name):
|
||||
return project_name, entry.path
|
||||
return project_name, path
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -766,9 +550,9 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
|
||||
project_permalink = generate_permalink(actual_project_name)
|
||||
|
||||
for name, entry in app_config.projects.items():
|
||||
for name, path in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(name):
|
||||
return ProjectConfig(name=name, home=Path(entry.path))
|
||||
return ProjectConfig(name=name, home=Path(path))
|
||||
|
||||
# otherwise raise error
|
||||
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
||||
|
||||
+7
-16
@@ -344,33 +344,24 @@ async def engine_session_factory(
|
||||
|
||||
global _engine, _session_maker
|
||||
|
||||
# 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
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
|
||||
try:
|
||||
# Verify that engine and session maker are initialized
|
||||
if created_engine is None: # pragma: no cover
|
||||
if _engine is None: # pragma: no cover
|
||||
logger.error("Database engine is None in engine_session_factory")
|
||||
raise RuntimeError("Database engine initialization failed")
|
||||
|
||||
if created_session_maker is None: # pragma: no cover
|
||||
if _session_maker is None: # pragma: no cover
|
||||
logger.error("Session maker is None in engine_session_factory")
|
||||
raise RuntimeError("Session maker initialization failed")
|
||||
|
||||
yield created_engine, created_session_maker
|
||||
yield _engine, _session_maker
|
||||
finally:
|
||||
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:
|
||||
if _engine:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
if _session_maker is created_session_maker:
|
||||
_session_maker = None
|
||||
|
||||
|
||||
|
||||
@@ -41,12 +41,7 @@ async def get_chatgpt_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ChatGPTImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
||||
@@ -58,12 +53,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
@@ -75,12 +65,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
|
||||
@@ -95,12 +80,7 @@ async def get_claude_conversations_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterDep = Annotated[
|
||||
@@ -114,12 +94,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
@@ -133,12 +108,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2ExternalDep = Annotated[
|
||||
@@ -155,12 +125,7 @@ async def get_claude_projects_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
||||
@@ -172,12 +137,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
@@ -191,12 +151,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2ExternalDep = Annotated[
|
||||
@@ -213,12 +168,7 @@ async def get_memory_json_importer(
|
||||
file_service: FileServiceDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with dependencies."""
|
||||
return MemoryJsonImporter(
|
||||
project_config.home,
|
||||
markdown_processor,
|
||||
file_service,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
@@ -230,12 +180,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
@@ -247,12 +192,7 @@ 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,
|
||||
project_name=project_config.name,
|
||||
)
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2ExternalDep = Annotated[
|
||||
|
||||
@@ -13,7 +13,6 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from basic_memory.deps.config import AppConfigDep
|
||||
from basic_memory.deps.db import SessionMakerDep
|
||||
from basic_memory.deps.projects import (
|
||||
ProjectIdDep,
|
||||
@@ -148,14 +147,13 @@ RelationRepositoryV2ExternalDep = Annotated[
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a backend-specific SearchRepository instance for the current project.
|
||||
|
||||
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
|
||||
based on database backend configuration.
|
||||
"""
|
||||
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
||||
@@ -164,10 +162,9 @@ SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)
|
||||
async def get_search_repository_v2( # pragma: no cover
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API."""
|
||||
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
|
||||
@@ -176,10 +173,9 @@ SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repositor
|
||||
async def get_search_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API (uses external_id)."""
|
||||
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2ExternalDep = Annotated[
|
||||
|
||||
@@ -8,7 +8,6 @@ This module provides service-layer dependencies:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol
|
||||
|
||||
from fastapi import Depends
|
||||
@@ -447,22 +446,13 @@ def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
|
||||
|
||||
class LocalTaskScheduler:
|
||||
"""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.
|
||||
"""
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task."""
|
||||
|
||||
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)
|
||||
@@ -471,15 +461,6 @@ 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)
|
||||
|
||||
@@ -489,12 +470,9 @@ async def get_task_scheduler(
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> TaskScheduler:
|
||||
"""Create a scheduler that maps task specs to coroutines."""
|
||||
|
||||
scheduler: LocalTaskScheduler | None = None
|
||||
|
||||
async def _reindex_entity(
|
||||
entity_id: int,
|
||||
resolve_relations: bool = False,
|
||||
@@ -506,18 +484,10 @@ async def get_task_scheduler(
|
||||
# Outcome: updates unresolved relations pointing to this entity
|
||||
if resolve_relations:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
# Trigger: semantic search enabled in local config.
|
||||
# Why: vector chunks are derived and should refresh after canonical reindex completes.
|
||||
# Outcome: schedules out-of-band vector sync without extending write latency.
|
||||
if app_config.semantic_search_enabled and scheduler is not None:
|
||||
scheduler.schedule("sync_entity_vectors", entity_id=entity_id)
|
||||
|
||||
async def _resolve_relations(entity_id: int, **_: Any) -> None:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
|
||||
async def _sync_entity_vectors(entity_id: int, **_: Any) -> None:
|
||||
await search_service.sync_entity_vectors(entity_id)
|
||||
|
||||
async def _sync_project(force_full: bool = False, **_: Any) -> None:
|
||||
await sync_service.sync(
|
||||
project_config.home,
|
||||
@@ -528,17 +498,14 @@ async def get_task_scheduler(
|
||||
async def _reindex_project(**_: Any) -> None:
|
||||
await search_service.reindex_all()
|
||||
|
||||
scheduler = LocalTaskScheduler(
|
||||
return LocalTaskScheduler(
|
||||
{
|
||||
"reindex_entity": _reindex_entity,
|
||||
"resolve_relations": _resolve_relations,
|
||||
"sync_entity_vectors": _sync_entity_vectors,
|
||||
"sync_project": _sync_project,
|
||||
"reindex_project": _reindex_project,
|
||||
},
|
||||
test_mode=app_config.is_test_env,
|
||||
}
|
||||
)
|
||||
return scheduler
|
||||
|
||||
|
||||
TaskSchedulerDep = Annotated[TaskScheduler, Depends(get_task_scheduler)]
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -30,7 +29,6 @@ class Importer[T: ImportResult]:
|
||||
base_path: Path,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
file_service: "FileService",
|
||||
project_name: Optional[str] = None,
|
||||
):
|
||||
"""Initialize the import service.
|
||||
|
||||
@@ -42,8 +40,6 @@ 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:
|
||||
@@ -77,26 +73,6 @@ 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,20 +51,11 @@ 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(chat, permalink)
|
||||
entity = self._format_chat_content(destination_folder, chat)
|
||||
|
||||
# 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
|
||||
@@ -92,7 +83,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
return self.handle_error("Failed to import ChatGPT conversations", e)
|
||||
|
||||
def _format_chat_content(
|
||||
self, conversation: Dict[str, Any], permalink: str
|
||||
self, folder: str, conversation: Dict[str, Any]
|
||||
) -> EntityMarkdown: # pragma: no cover
|
||||
"""Convert chat conversation to Basic Memory entity.
|
||||
|
||||
@@ -114,6 +105,10 @@ 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"],
|
||||
@@ -131,7 +126,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"title": conversation["title"],
|
||||
"created": format_timestamp(created_at),
|
||||
"modified": format_timestamp(modified_at),
|
||||
"permalink": permalink,
|
||||
"permalink": f"{folder}/{date_prefix}-{clean_title}",
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
|
||||
@@ -54,27 +54,18 @@ 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
|
||||
@@ -93,11 +84,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.
|
||||
|
||||
@@ -111,6 +102,11 @@ 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,28 +63,17 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
await self.file_service.ensure_directory(docs_dir)
|
||||
|
||||
# Import prompt template if it exists
|
||||
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)
|
||||
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)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
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)
|
||||
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"
|
||||
await self.write_entity(entity, file_path)
|
||||
docs_imported += 1
|
||||
|
||||
@@ -100,7 +89,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], permalink: str
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
|
||||
) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity.
|
||||
|
||||
@@ -116,6 +105,17 @@ 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], permalink: str
|
||||
self, project: Dict[str, Any], destination_folder: str = ""
|
||||
) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity.
|
||||
|
||||
@@ -155,6 +155,16 @@ 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,12 +80,11 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
relative_path = (
|
||||
permalink = (
|
||||
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 = (
|
||||
@@ -110,6 +109,7 @@ 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,8 +1,6 @@
|
||||
"""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
|
||||
|
||||
@@ -116,7 +114,7 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
|
||||
rel_type = before
|
||||
|
||||
# Get target
|
||||
target = normalize_project_reference(content[start + 2 : end].strip())
|
||||
target = content[start + 2 : end].strip()
|
||||
|
||||
# Look for context after
|
||||
after = content[end + 2 :].strip()
|
||||
@@ -162,7 +160,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
|
||||
# No matching ]] found
|
||||
break
|
||||
|
||||
target = normalize_project_reference(content[start + 2 : end].strip())
|
||||
target = content[start + 2 : end].strip()
|
||||
if target:
|
||||
relations.append({"type": "links_to", "target": target, "context": None})
|
||||
|
||||
|
||||
@@ -1,190 +1,169 @@
|
||||
import os
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
|
||||
from httpx import ASGITransport, AsyncClient, Timeout
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
def _force_local_mode() -> bool:
|
||||
"""Check if local mode is forced via environment variable."""
|
||||
"""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
|
||||
"""
|
||||
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)."""
|
||||
"""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)
|
||||
"""
|
||||
global _client_factory
|
||||
_client_factory = factory
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_client(
|
||||
project_name: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
async def get_client() -> AsyncIterator[AsyncClient]:
|
||||
"""Get an AsyncClient as a context manager.
|
||||
|
||||
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.
|
||||
This function provides proper resource management for HTTP clients,
|
||||
ensuring connections are closed after use. It supports three modes:
|
||||
|
||||
1. **Factory injection** (cloud app, tests):
|
||||
If a custom factory is set via set_client_factory(), use that.
|
||||
|
||||
2. **CLI cloud mode**:
|
||||
When cloud_mode_enabled is True, create HTTP client with auth
|
||||
token from CLIAuth for requests to cloud proxy endpoint.
|
||||
|
||||
3. **Local mode** (default):
|
||||
Use ASGI transport for in-process requests to local FastAPI app.
|
||||
|
||||
Usage:
|
||||
async with get_client() as client:
|
||||
response = await client.get("/path")
|
||||
|
||||
Yields:
|
||||
AsyncClient: Configured HTTP client for the current mode
|
||||
|
||||
Raises:
|
||||
RuntimeError: If cloud mode is enabled but user is not authenticated
|
||||
"""
|
||||
if _client_factory:
|
||||
# Use injected factory (cloud app, tests)
|
||||
async with _client_factory() as client:
|
||||
yield client
|
||||
return
|
||||
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
|
||||
)
|
||||
|
||||
config = ConfigManager().config
|
||||
timeout = _build_timeout()
|
||||
|
||||
# --- 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():
|
||||
# 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
|
||||
if _force_local_mode():
|
||||
logger.info("Explicit local routing enabled - using ASGI client")
|
||||
async with _asgi_client(timeout) as client:
|
||||
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
|
||||
return
|
||||
elif config.cloud_mode_enabled:
|
||||
# CLI cloud mode: inject auth when creating client
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
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
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
token = await auth.get_valid_token()
|
||||
|
||||
# --- 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:
|
||||
if not token:
|
||||
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
|
||||
"Cloud mode enabled but not authenticated. "
|
||||
"Run 'basic-memory cloud login' first."
|
||||
)
|
||||
|
||||
logger.info(f"Project '{project_name}' is local mode - using ASGI client")
|
||||
async with _asgi_client(timeout) as client:
|
||||
yield client
|
||||
return
|
||||
|
||||
# --- Default fallback ---
|
||||
logger.info("Default routing - using ASGI client for local Basic Memory API")
|
||||
async with _asgi_client(timeout) as client:
|
||||
yield client
|
||||
# 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
|
||||
|
||||
|
||||
def create_client() -> AsyncClient:
|
||||
"""Create an HTTP client based on explicit routing flags.
|
||||
"""Create an HTTP client based on configuration.
|
||||
|
||||
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
|
||||
"""
|
||||
timeout = _build_timeout()
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
if _force_local_mode() or not _force_cloud_mode():
|
||||
# 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)
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
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)
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ from basic_memory.mcp.clients.memory import MemoryClient
|
||||
from basic_memory.mcp.clients.directory import DirectoryClient
|
||||
from basic_memory.mcp.clients.resource import ResourceClient
|
||||
from basic_memory.mcp.clients.project import ProjectClient
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeClient",
|
||||
@@ -26,5 +25,4 @@ __all__ = [
|
||||
"DirectoryClient",
|
||||
"ResourceClient",
|
||||
"ProjectClient",
|
||||
"SchemaClient",
|
||||
]
|
||||
|
||||
@@ -224,12 +224,11 @@ class KnowledgeClient:
|
||||
|
||||
# --- Resolution ---
|
||||
|
||||
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
|
||||
async def resolve_entity(self, identifier: str) -> 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)
|
||||
@@ -240,7 +239,7 @@ class KnowledgeClient:
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/resolve",
|
||||
json={"identifier": identifier, "strict": strict},
|
||||
json={"identifier": identifier},
|
||||
)
|
||||
data = response.json()
|
||||
return data["external_id"]
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Typed client for schema API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
|
||||
"""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
from basic_memory.schemas.schema import (
|
||||
ValidationReport,
|
||||
InferenceReport,
|
||||
DriftReport,
|
||||
)
|
||||
|
||||
|
||||
class SchemaClient:
|
||||
"""Typed client for schema operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/schema/*
|
||||
- Response validation via Pydantic models
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = SchemaClient(http_client, project_id)
|
||||
report = await client.validate(entity_type="Person")
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the schema client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/schema"
|
||||
|
||||
async def validate(
|
||||
self,
|
||||
*,
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
) -> ValidationReport:
|
||||
"""Validate notes against their resolved schemas.
|
||||
|
||||
Args:
|
||||
entity_type: Optional entity type to batch-validate
|
||||
identifier: Optional specific note to validate
|
||||
|
||||
Returns:
|
||||
ValidationReport with per-note results
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if entity_type:
|
||||
params["entity_type"] = entity_type
|
||||
if identifier:
|
||||
params["identifier"] = identifier
|
||||
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/validate",
|
||||
params=params,
|
||||
)
|
||||
return ValidationReport.model_validate(response.json())
|
||||
|
||||
async def infer(
|
||||
self,
|
||||
entity_type: str,
|
||||
*,
|
||||
threshold: float = 0.25,
|
||||
) -> InferenceReport:
|
||||
"""Infer a schema from existing notes of a given type.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to analyze
|
||||
threshold: Minimum frequency for optional fields (0-1)
|
||||
|
||||
Returns:
|
||||
InferenceReport with frequency data and suggested schema
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/infer",
|
||||
params={"entity_type": entity_type, "threshold": threshold},
|
||||
)
|
||||
return InferenceReport.model_validate(response.json())
|
||||
|
||||
async def diff(self, entity_type: str) -> DriftReport:
|
||||
"""Show drift between schema definition and actual usage.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to check for drift
|
||||
|
||||
Returns:
|
||||
DriftReport with detected differences
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/diff/{entity_type}",
|
||||
)
|
||||
return DriftReport.model_validate(response.json())
|
||||
@@ -39,6 +39,7 @@ 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)
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Formatting helpers for MCP tool outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult
|
||||
|
||||
ANSI_RESET = "\x1b[0m"
|
||||
ANSI_BOLD = "\x1b[1m"
|
||||
ANSI_DIM = "\x1b[2m"
|
||||
ANSI_CYAN = "\x1b[36m"
|
||||
|
||||
|
||||
def _apply_style(text: str, style: str, enabled: bool) -> str:
|
||||
if not enabled:
|
||||
return text
|
||||
return f"{style}{text}{ANSI_RESET}"
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return text
|
||||
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
return "\n".join(lines[idx + 1 :]).lstrip()
|
||||
return text
|
||||
|
||||
|
||||
def _parse_title(text: str) -> str | None:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _truncate(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return text[:width]
|
||||
return text[: width - 3] + "..."
|
||||
|
||||
|
||||
def _make_separator(widths: Sequence[int]) -> str:
|
||||
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
|
||||
|
||||
|
||||
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
|
||||
cells = []
|
||||
for value, width in zip(values, widths, strict=True):
|
||||
cells.append(f" {_truncate(value, width).ljust(width)} ")
|
||||
return "|" + "|".join(cells) + "|"
|
||||
|
||||
|
||||
def _get_result_tags(result: SearchResult) -> str:
|
||||
metadata = result.metadata or {}
|
||||
if isinstance(metadata, dict):
|
||||
tags = metadata.get("tags")
|
||||
if isinstance(tags, list):
|
||||
return ", ".join(str(tag) for tag in tags if tag)
|
||||
return ""
|
||||
|
||||
|
||||
def _get_result_path(result: SearchResult) -> str:
|
||||
return result.permalink or result.file_path or ""
|
||||
|
||||
|
||||
def format_search_results_ascii(
|
||||
result: SearchResponse,
|
||||
query: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format search results as an ASCII table for TUI clients."""
|
||||
|
||||
results = result.results or []
|
||||
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header_line]
|
||||
|
||||
if query:
|
||||
lines.append(f"Query: {query}")
|
||||
|
||||
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:
|
||||
lines.append("No results.")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
|
||||
rows = []
|
||||
for idx, item in enumerate(results, start=1):
|
||||
rows.append(
|
||||
[
|
||||
str(idx),
|
||||
item.title or "Untitled",
|
||||
item.type.value if hasattr(item.type, "value") else str(item.type),
|
||||
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
|
||||
_get_result_path(item),
|
||||
_get_result_tags(item),
|
||||
]
|
||||
)
|
||||
|
||||
max_widths = [3, 32, 10, 7, 36, 24]
|
||||
widths = []
|
||||
for index, header in enumerate(headers):
|
||||
column_values = [header] + [row[index] for row in rows]
|
||||
max_len = max(len(value) for value in column_values)
|
||||
widths.append(min(max_widths[index], max_len))
|
||||
|
||||
table = [_make_separator(widths)]
|
||||
header_row = _format_row(headers, widths)
|
||||
if color:
|
||||
header_cells = []
|
||||
for value, width in zip(headers, widths, strict=True):
|
||||
padded = f" {_truncate(value, width).ljust(width)} "
|
||||
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
|
||||
header_row = "|" + "|".join(header_cells) + "|"
|
||||
table.append(header_row)
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
for row in rows:
|
||||
table.append(_format_row(row, widths))
|
||||
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
lines.append("")
|
||||
lines.extend(table)
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def format_note_preview_ascii(
|
||||
content: str,
|
||||
identifier: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format note content for ASCII/TUI display."""
|
||||
|
||||
identifier = identifier or ""
|
||||
cleaned = _strip_frontmatter(content)
|
||||
title = _parse_title(cleaned) or identifier or "Note Preview"
|
||||
|
||||
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header, f"Title: {title}"]
|
||||
|
||||
if identifier:
|
||||
lines.append(f"Identifier: {identifier}")
|
||||
|
||||
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
|
||||
|
||||
if content.strip():
|
||||
lines.append(content.rstrip())
|
||||
else:
|
||||
lines.append("(empty note)")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
@@ -8,59 +8,66 @@ The resolve_project_parameter function is a thin wrapper for backwards
|
||||
compatibility with existing MCP tools.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Optional, List, Tuple
|
||||
|
||||
from typing import Optional, List
|
||||
from httpx import AsyncClient
|
||||
from httpx._types import (
|
||||
HeaderTypes,
|
||||
)
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
from basic_memory.config import ConfigManager
|
||||
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.
|
||||
"""Resolve project parameter using three-tier hierarchy.
|
||||
|
||||
This is a thin wrapper around ProjectResolver for backwards compatibility.
|
||||
New code should consider using ProjectResolver directly for more detailed
|
||||
resolution information.
|
||||
|
||||
Resolution order:
|
||||
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. EXPLICIT: project parameter passed directly
|
||||
3. DEFAULT: default_project from config (if set)
|
||||
4. Fallback: discovery (if allowed) → NONE
|
||||
if cloud_mode:
|
||||
project is required (unless allow_discovery=True for tools that support discovery mode)
|
||||
else:
|
||||
Resolution order:
|
||||
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
|
||||
2. Explicit project parameter - medium priority
|
||||
3. Default project if default_project_mode=true - lowest priority
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None for discovery mode
|
||||
allow_discovery: If True, allows returning None in cloud mode 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 default_project is None:
|
||||
if cloud_mode is None or default_project_mode is None or default_project is None:
|
||||
config = ConfigManager().config
|
||||
default_project = config.default_project
|
||||
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
|
||||
|
||||
# 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)
|
||||
@@ -76,100 +83,6 @@ 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,
|
||||
@@ -198,7 +111,7 @@ async def get_active_project(
|
||||
project_names = await get_project_names(client, headers)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"Either set 'default_project' in config, or use 'project' argument.\n"
|
||||
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
||||
f"Available projects: {project_names}"
|
||||
)
|
||||
|
||||
@@ -237,96 +150,6 @@ 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.
|
||||
|
||||
@@ -341,70 +164,3 @@ def add_project_metadata(result: str, project_name: str) -> str:
|
||||
Result with project session tracking metadata
|
||||
"""
|
||||
return f"{result}\n\n[Session: Using project '{project_name}']"
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Solves the bootstrap problem: we need to know the project name to choose
|
||||
the right client (local vs cloud), but we need the client to validate
|
||||
the project. This helper resolves the project from config first (no
|
||||
network), creates the correctly-routed client, then validates via API.
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
workspace: Optional cloud workspace selector (tenant_id or unique name)
|
||||
context: Optional FastMCP context for caching
|
||||
|
||||
Yields:
|
||||
Tuple of (client, active_project)
|
||||
|
||||
Raises:
|
||||
ValueError: If no project can be resolved
|
||||
RuntimeError: If cloud project but no API key configured
|
||||
"""
|
||||
# Deferred import to avoid circular dependency
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
# Step 1: Resolve project name from config (no network call)
|
||||
resolved_project = await resolve_project_parameter(project)
|
||||
if not resolved_project:
|
||||
# Fall back to local client to discover projects and raise helpful error
|
||||
async with get_client() as client:
|
||||
project_names = await get_project_names(client)
|
||||
raise ValueError(
|
||||
"No project specified. "
|
||||
"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,
|
||||
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 set: Simplified instructions with automatic project fallback
|
||||
- No default project: Project discovery and selection guidance
|
||||
- Default project mode: Simplified instructions with automatic project
|
||||
- Regular mode: Project discovery and selection guidance
|
||||
- CLI constraint mode: Single project constraint information
|
||||
|
||||
Returns:
|
||||
@@ -30,32 +30,34 @@ def ai_assistant_guide() -> str:
|
||||
# Check configuration for mode-specific instructions
|
||||
config = ConfigManager().config
|
||||
|
||||
# Add mode-specific header based on whether a default project is configured
|
||||
if config.default_project:
|
||||
# Add mode-specific header
|
||||
mode_info = ""
|
||||
if config.default_project_mode: # pragma: no cover
|
||||
mode_info = f"""
|
||||
# Default Project Active
|
||||
# 🎯 Default Project Mode Active
|
||||
|
||||
**Current Configuration**: Operations automatically fall back to project '{config.default_project}'
|
||||
**Current Configuration**: All operations automatically use 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")` - uses '{config.default_project}'
|
||||
- `write_note(title="Note", content="...", folder="docs")` ✅
|
||||
- Project parameter is optional and will default to '{config.default_project}'
|
||||
- To use a different project, explicitly specify: `project="other-project"`
|
||||
|
||||
---
|
||||
────────────────────────────────────────
|
||||
|
||||
"""
|
||||
else: # pragma: no cover
|
||||
mode_info = """
|
||||
# Multi-Project Mode
|
||||
# 🔧 Multi-Project Mode Active
|
||||
|
||||
**Current Configuration**: No default project set — project parameter required for all operations
|
||||
**Current Configuration**: 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
|
||||
|
||||
---
|
||||
────────────────────────────────────────
|
||||
|
||||
"""
|
||||
|
||||
@@ -63,7 +65,6 @@ def ai_assistant_guide() -> str:
|
||||
enhanced_content = mode_info + content
|
||||
|
||||
logger.info(
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) "
|
||||
f"with default_project: {config.default_project or 'none'}"
|
||||
f"Loaded AI assistant guide ({len(enhanced_content)} chars) with mode: {'default_project' if config.default_project_mode else 'multi_project'}"
|
||||
)
|
||||
return enhanced_content
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""MCP resources for Basic Memory."""
|
||||
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
|
||||
# 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",
|
||||
]
|
||||
@@ -14,30 +14,36 @@ 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
|
||||
|
||||
**Resolution priority:**
|
||||
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
|
||||
All tools require explicit project specification.
|
||||
|
||||
**Three-tier resolution:**
|
||||
1. CLI constraint: `--project name` (highest priority)
|
||||
2. Explicit parameter: `project="name"` in tool calls
|
||||
3. Default project: `default_project` in config (fallback)
|
||||
3. Default mode: `default_project_mode=true` 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
|
||||
### Default Project Mode
|
||||
|
||||
When `default_project` is set in config:
|
||||
When `default_project_mode=true`:
|
||||
```python
|
||||
# These are equivalent:
|
||||
await write_note("Note", "Content", "folder")
|
||||
await write_note("Note", "Content", "folder", project="main")
|
||||
```
|
||||
|
||||
When no `default_project` is configured:
|
||||
When `default_project_mode=false` (default):
|
||||
```python
|
||||
# Project required:
|
||||
await write_note("Note", "Content", "folder", project="main") # ✓
|
||||
@@ -53,7 +59,7 @@ await write_note(
|
||||
title="Topic",
|
||||
content="# Topic\n## Observations\n- [category] fact\n## Relations\n- relates_to [[Other]]",
|
||||
folder="notes",
|
||||
project="main" # Optional if default_project is set in config
|
||||
project="main" # Required unless default_project_mode=true
|
||||
)
|
||||
```
|
||||
|
||||
@@ -137,11 +143,12 @@ await write_note(
|
||||
### 1. Project Management
|
||||
|
||||
**Single-project users:**
|
||||
- Set `default_project` in config (e.g., `"main"`)
|
||||
- Simpler tool calls — project parameter is optional
|
||||
- Enable `default_project_mode=true`
|
||||
- Simpler tool calls
|
||||
|
||||
**Multi-project users:**
|
||||
- Always specify project explicitly in tool calls
|
||||
- Keep `default_project_mode=false`
|
||||
- Always specify project explicitly
|
||||
|
||||
**Discovery:**
|
||||
```python
|
||||
@@ -193,7 +200,7 @@ Background information
|
||||
**Missing project:**
|
||||
```python
|
||||
try:
|
||||
await search_notes(query="test") # Fails if no default_project configured
|
||||
await search_notes(query="test") # Missing project parameter - will error
|
||||
except:
|
||||
# Show available projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Basic Memory Cloud (optional)
|
||||
|
||||
Basic Memory Cloud is an optional add-on for users who want hosted access and sync.
|
||||
|
||||
- Hosted access to your knowledge
|
||||
- Cross-device sync
|
||||
- Mobile and web access
|
||||
- Multi-client workflows (Claude, ChatGPT, Gemini, and others)
|
||||
|
||||
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
|
||||
|
||||
Get started:
|
||||
|
||||
```bash
|
||||
bm cloud login
|
||||
```
|
||||
@@ -38,8 +38,8 @@ async def project_info(
|
||||
|
||||
Args:
|
||||
project: Optional project name. If not provided, uses default_project
|
||||
from config or CLI constraint. If unknown, use
|
||||
list_memory_projects() to discover available projects.
|
||||
(if default_project_mode=true) or CLI constraint. If unknown,
|
||||
use list_memory_projects() to discover available projects.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Release Notes
|
||||
|
||||
## 2026-02-06
|
||||
|
||||
- Added optional cloud discovery copy to CLI first-run and promo-version notices.
|
||||
- Added MCP tools for opt-in cloud discovery: `cloud_info` and `release_notes`.
|
||||
- Updated docs and README copy to keep cloud messaging explicit and optional.
|
||||
|
||||
Cloud remains optional for open-source users.
|
||||
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
|
||||
|
||||
Get started:
|
||||
|
||||
```bash
|
||||
bm cloud login
|
||||
```
|
||||
@@ -1,89 +0,0 @@
|
||||
"""UI resources for MCP Apps integration."""
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.ui import load_html, load_variant_html
|
||||
|
||||
# FastMCP's MIME type validator currently accepts only type/subtype, so we
|
||||
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
|
||||
MIME_TYPE = "text/html"
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results",
|
||||
name="Basic Memory Search Results",
|
||||
description="Search results UI for Basic Memory tools.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui() -> str:
|
||||
return load_variant_html("search-results")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview",
|
||||
name="Basic Memory Note Preview",
|
||||
description="Note preview UI for Basic Memory read_note tool.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui() -> str:
|
||||
return load_variant_html("note-preview")
|
||||
|
||||
|
||||
# Variant-specific resource URIs for bakeoff comparisons.
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/vanilla",
|
||||
name="Basic Memory Search Results (Vanilla)",
|
||||
description="Vanilla HTML search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_vanilla() -> str:
|
||||
return load_html("search-results-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/tool-ui",
|
||||
name="Basic Memory Search Results (Tool UI)",
|
||||
description="Tool UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_tool_ui() -> str:
|
||||
return load_html("search-results-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui",
|
||||
name="Basic Memory Search Results (MCP UI)",
|
||||
description="MCP UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_mcp_ui() -> str:
|
||||
return load_html("search-results-mcp-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/vanilla",
|
||||
name="Basic Memory Note Preview (Vanilla)",
|
||||
description="Vanilla HTML note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_vanilla() -> str:
|
||||
return load_html("note-preview-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/tool-ui",
|
||||
name="Basic Memory Note Preview (Tool UI)",
|
||||
description="Tool UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_tool_ui() -> str:
|
||||
return load_html("note-preview-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui",
|
||||
name="Basic Memory Note Preview (MCP UI)",
|
||||
description="MCP UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_mcp_ui() -> str:
|
||||
return load_html("note-preview-mcp-ui.html")
|
||||
@@ -2,14 +2,12 @@
|
||||
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
|
||||
|
||||
@@ -28,39 +26,7 @@ async def lifespan(app: FastMCP):
|
||||
container = McpContainer.create()
|
||||
set_container(container)
|
||||
|
||||
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")
|
||||
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
|
||||
# Track if we created the engine (vs test fixtures providing it)
|
||||
# This prevents disposing an engine provided by test fixtures when
|
||||
|
||||
@@ -11,19 +11,13 @@ 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
|
||||
|
||||
# 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
|
||||
from basic_memory.mcp.tools.release_notes import release_notes
|
||||
from basic_memory.mcp.tools.search import search_notes, search_by_metadata
|
||||
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,
|
||||
@@ -33,13 +27,9 @@ from basic_memory.mcp.tools.project_management import (
|
||||
# ChatGPT-compatible tools
|
||||
from basic_memory.mcp.tools.chatgpt_tools import search, fetch
|
||||
|
||||
# Schema tools
|
||||
from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
"canvas",
|
||||
"cloud_info",
|
||||
"create_memory_project",
|
||||
"delete_note",
|
||||
"delete_project",
|
||||
@@ -47,20 +37,13 @@ __all__ = [
|
||||
"fetch",
|
||||
"list_directory",
|
||||
"list_memory_projects",
|
||||
"list_workspaces",
|
||||
"move_note",
|
||||
"read_content",
|
||||
"read_note",
|
||||
"release_notes",
|
||||
# "read_note_ui",
|
||||
"recent_activity",
|
||||
"schema_diff",
|
||||
"schema_infer",
|
||||
"schema_validate",
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
# "search_notes_ui",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -1,176 +1,20 @@
|
||||
"""Build context tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
ContextResult,
|
||||
EntitySummary,
|
||||
GraphContext,
|
||||
MemoryUrl,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
memory_url_path,
|
||||
)
|
||||
|
||||
# --- 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.
|
||||
@@ -187,24 +31,18 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
|
||||
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,
|
||||
) -> dict | str:
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion within a specific project.
|
||||
|
||||
This tool enables natural continuation of discussions by loading relevant context
|
||||
@@ -212,9 +50,8 @@ async def build_context(
|
||||
a rich context graph of related information.
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects using a unified priority chain (same in local and cloud modes):
|
||||
Single Project Mode → project parameter → default project.
|
||||
Uses default project automatically. Specify `project` parameter to target a different project.
|
||||
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
||||
If project unknown, use list_memory_projects() or recent_activity() first.
|
||||
|
||||
Args:
|
||||
project: Project name to build context from. Optional - server will resolve using hierarchy.
|
||||
@@ -225,13 +62,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:
|
||||
dict (output_format="json"): Slimmed JSON with redundant fields removed
|
||||
str (output_format="text"): Compact markdown representation
|
||||
GraphContext containing:
|
||||
- primary_results: Content matching the memory:// URI
|
||||
- related_results: Connected content via relations
|
||||
- metadata: Context building details
|
||||
|
||||
Examples:
|
||||
# Continue a specific discussion
|
||||
@@ -240,8 +77,11 @@ async def build_context(
|
||||
# Get deeper context about a component
|
||||
build_context("work-docs", "memory://components/memory-service", depth=2)
|
||||
|
||||
# Get text output for compact context
|
||||
build_context("research", "memory://specs/search", output_format="text")
|
||||
# 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")
|
||||
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
@@ -259,25 +99,20 @@ async def build_context(
|
||||
|
||||
# URL is already validated and normalized by MemoryUrl type annotation
|
||||
|
||||
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)
|
||||
async with get_client() as client:
|
||||
# Get the active project using the new stateless approach
|
||||
active_project = await get_active_project(client, 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)
|
||||
graph = await memory_client.build_context(
|
||||
resolved_path,
|
||||
return await memory_client.build_context(
|
||||
memory_url_path(url),
|
||||
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)
|
||||
|
||||
@@ -9,7 +9,8 @@ from typing import Dict, List, Any, Optional
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
|
||||
@@ -23,7 +24,6 @@ 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.
|
||||
@@ -94,7 +94,9 @@ async def canvas(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
file_path = f"{directory}/{file_title}"
|
||||
|
||||
@@ -13,41 +13,22 @@ 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[SearchResult] | list[dict[str, Any]] | dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
def _format_search_results_for_chatgpt(results: SearchResponse) -> 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 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__}")
|
||||
|
||||
for result in results.results:
|
||||
formatted_result = {
|
||||
"id": permalink or f"doc-{len(formatted_results)}",
|
||||
"title": title if isinstance(title, str) and title.strip() else "Untitled",
|
||||
"url": permalink or "",
|
||||
"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 "",
|
||||
}
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
@@ -121,7 +102,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -135,11 +115,10 @@ async def search(
|
||||
}
|
||||
else:
|
||||
# Format successful results for ChatGPT
|
||||
raw_results = results.get("results", []) if isinstance(results, dict) else []
|
||||
formatted_results = _format_search_results_for_chatgpt(raw_results)
|
||||
formatted_results = _format_search_results_for_chatgpt(results)
|
||||
search_results = {
|
||||
"results": formatted_results,
|
||||
"total_count": len(raw_results), # Use actual count from results
|
||||
"total_count": len(results.results), # Use actual count from results
|
||||
"query": query,
|
||||
}
|
||||
logger.info(f"Search completed: {len(formatted_results)} results returned")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Cloud information MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@mcp.tool("cloud_info")
|
||||
def cloud_info() -> str:
|
||||
"""Return optional Basic Memory Cloud information and setup guidance."""
|
||||
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
|
||||
return content_path.read_text(encoding="utf-8")
|
||||
@@ -1,12 +1,13 @@
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
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_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
|
||||
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
||||
@@ -151,10 +152,8 @@ 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 | dict:
|
||||
) -> bool | str:
|
||||
"""Delete a note or directory from the knowledge base.
|
||||
|
||||
Permanently removes a note or directory from the specified project. For single notes,
|
||||
@@ -175,8 +174,6 @@ 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:
|
||||
@@ -219,11 +216,13 @@ 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, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
logger.debug(
|
||||
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
|
||||
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {project}"
|
||||
)
|
||||
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
@@ -234,15 +233,6 @@ 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 = [
|
||||
@@ -300,41 +290,18 @@ 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
|
||||
)
|
||||
@@ -347,36 +314,14 @@ 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,11 +1,12 @@
|
||||
"""Edit note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@@ -131,13 +132,11 @@ 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 | dict:
|
||||
) -> str:
|
||||
"""Edit an existing markdown note in the knowledge base.
|
||||
|
||||
Makes targeted changes to existing notes without rewriting the entire content.
|
||||
@@ -161,8 +160,6 @@ 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:
|
||||
@@ -215,7 +212,9 @@ 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, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
||||
|
||||
# Validate operation
|
||||
@@ -314,29 +313,11 @@ 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
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@@ -17,7 +18,6 @@ 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.
|
||||
@@ -62,7 +62,9 @@ async def list_directory(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
logger.debug(
|
||||
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Move note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
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.server import mcp
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -348,10 +349,8 @@ 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 | dict:
|
||||
) -> str:
|
||||
"""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,
|
||||
@@ -370,8 +369,6 @@ 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:
|
||||
@@ -415,11 +412,13 @@ async def move_note(
|
||||
- Re-indexes the entity for search
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
logger.debug(
|
||||
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {active_project.name}"
|
||||
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {project}"
|
||||
)
|
||||
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Validate destination path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if not validate_project_path(destination_path, project_path):
|
||||
@@ -428,16 +427,6 @@ 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.
|
||||
@@ -461,19 +450,6 @@ 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 = [
|
||||
@@ -515,17 +491,6 @@ 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)}
|
||||
@@ -550,16 +515,6 @@ 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
|
||||
@@ -584,16 +539,6 @@ 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
|
||||
|
||||
@@ -630,16 +575,6 @@ 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
|
||||
|
||||
@@ -667,15 +602,6 @@ 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 = [
|
||||
@@ -700,15 +626,5 @@ 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,7 +5,6 @@ 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
|
||||
@@ -15,71 +14,65 @@ from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@mcp.tool("list_memory_projects")
|
||||
async def list_memory_projects(
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
async def list_memory_projects(context: Context | None = None) -> str:
|
||||
"""List all available projects with their status.
|
||||
|
||||
Args:
|
||||
output_format: "text" returns the existing human-readable project list.
|
||||
"json" returns structured project metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
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()
|
||||
"""
|
||||
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."
|
||||
return result
|
||||
else:
|
||||
# Show all projects with session guidance
|
||||
result = "Available projects:\n"
|
||||
|
||||
result = "Available projects:\n"
|
||||
for project in project_list.projects:
|
||||
result += f"• {project.name}\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."
|
||||
|
||||
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,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
project_name: str, project_path: str, set_default: bool = False, context: Context | None = None
|
||||
) -> str:
|
||||
"""Create a new Basic Memory project.
|
||||
|
||||
Creates a new project with the specified name and path. The project directory
|
||||
@@ -89,9 +82,6 @@ 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
|
||||
@@ -104,19 +94,6 @@ 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
|
||||
@@ -132,46 +109,8 @@ 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,8 +15,9 @@ 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, resolve_project_and_path
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
@@ -150,10 +151,7 @@ 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,
|
||||
workspace: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
path: str, project: Optional[str] = None, context: Context | None = None
|
||||
) -> dict:
|
||||
"""Read a file's raw content by path or permalink.
|
||||
|
||||
@@ -204,18 +202,14 @@ async def read_content(
|
||||
"""
|
||||
logger.info("Reading file", path=path, project=project)
|
||||
|
||||
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)
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
url = memory_url_path(path)
|
||||
|
||||
# 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(raw_path, project_path) or not validate_project_path(
|
||||
url, project_path
|
||||
):
|
||||
if not validate_project_path(url, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
path=path,
|
||||
|
||||
@@ -1,84 +1,37 @@
|
||||
"""Read note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
|
||||
import yaml
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
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.",
|
||||
# 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["text", "json"] = "text",
|
||||
include_frontmatter: bool = False,
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
) -> str:
|
||||
"""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,
|
||||
returning the raw markdown content including observations, relations, and metadata.
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects using a unified priority chain (same in local and cloud modes):
|
||||
Single Project Mode → project parameter → default project.
|
||||
Uses default project automatically. Specify `project` parameter to target a different project.
|
||||
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
||||
If project unknown, use list_memory_projects() or recent_activity() first.
|
||||
|
||||
This tool will try multiple lookup strategies to find the most relevant note:
|
||||
1. Direct permalink lookup
|
||||
@@ -93,10 +46,6 @@ 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: "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:
|
||||
@@ -127,18 +76,16 @@ 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, 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)
|
||||
async with get_client() as client:
|
||||
# Get and validate the project
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Validate identifier 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(identifier) if identifier.startswith("memory://") else identifier
|
||||
processed_path = entity_path
|
||||
# We need to check both the raw identifier and the processed path
|
||||
processed_path = memory_url_path(identifier)
|
||||
project_path = active_project.home
|
||||
|
||||
if not validate_project_path(raw_path, project_path) or not validate_project_path(
|
||||
if not validate_project_path(identifier, project_path) or not validate_project_path(
|
||||
processed_path, project_path
|
||||
):
|
||||
logger.warning(
|
||||
@@ -147,18 +94,10 @@ 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}"
|
||||
)
|
||||
@@ -170,59 +109,9 @@ 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, strict=True)
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path)
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
@@ -230,8 +119,6 @@ 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 == "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}")
|
||||
@@ -240,49 +127,26 @@ 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=active_project.name,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
context=context,
|
||||
query=identifier, search_type="title", project=project, context=context
|
||||
)
|
||||
|
||||
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):
|
||||
# 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:
|
||||
try:
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
_result_permalink(result) or "", strict=True
|
||||
)
|
||||
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
||||
|
||||
# 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 exact title search: {_result_permalink(result)}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
logger.info(f"Found note by title search: {result.permalink}")
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
|
||||
f"Failed to fetch content for found title match {result.permalink}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -292,32 +156,17 @@ 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=active_project.name,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
context=context,
|
||||
query=identifier, search_type="text", project=project, context=context
|
||||
)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
text_candidates = _search_results(text_results)
|
||||
if not text_candidates:
|
||||
if output_format == "json":
|
||||
return _empty_json_payload()
|
||||
# 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
|
||||
return format_not_found_message(active_project.name, identifier)
|
||||
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])
|
||||
else:
|
||||
# We found some related results
|
||||
return format_related_results(active_project.name, identifier, text_results.results[:5])
|
||||
|
||||
|
||||
def format_not_found_message(project: str | None, identifier: str) -> str:
|
||||
@@ -377,31 +226,14 @@ 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}. {title or "Untitled"}
|
||||
- **Type**: {normalized_type or "entity"}
|
||||
- **Permalink**: {permalink or "unknown"}
|
||||
## {i + 1}. {result.title}
|
||||
- **Type**: {result.type.value}
|
||||
- **Permalink**: {result.permalink}
|
||||
|
||||
You can read this note with:
|
||||
```
|
||||
read_note(project="{project}", identifier="{permalink or ""}")
|
||||
read_note(project="{project}", {result.permalink}")
|
||||
```
|
||||
|
||||
""")
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"""Recent activity tool for Basic Memory MCP server."""
|
||||
|
||||
from datetime import timezone
|
||||
from typing import List, Union, Optional, Literal
|
||||
from typing import List, Union, 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_project_client,
|
||||
resolve_project_parameter,
|
||||
)
|
||||
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
@@ -40,10 +37,8 @@ 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 | list[dict]:
|
||||
) -> str:
|
||||
"""Get recent activity for a specific project or across all projects.
|
||||
|
||||
Project Resolution:
|
||||
@@ -79,8 +74,6 @@ 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:
|
||||
@@ -106,51 +99,50 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
# Build common parameters for API calls
|
||||
params: dict = {
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"max_related": 10,
|
||||
}
|
||||
if depth:
|
||||
params["depth"] = depth
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe # pyright: ignore
|
||||
async with get_client() as client:
|
||||
# Build common parameters for API calls
|
||||
params = {
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"max_related": 10,
|
||||
}
|
||||
if depth:
|
||||
params["depth"] = depth
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe # pyright: ignore
|
||||
|
||||
# Validate and convert type parameter
|
||||
if type:
|
||||
# Convert single string to list
|
||||
if isinstance(type, str):
|
||||
type_list = [type]
|
||||
else:
|
||||
type_list = type
|
||||
# Validate and convert type parameter
|
||||
if type:
|
||||
# Convert single string to list
|
||||
if isinstance(type, str):
|
||||
type_list = [type]
|
||||
else:
|
||||
type_list = type
|
||||
|
||||
# Validate each type against SearchItemType enum
|
||||
validated_types = []
|
||||
for t in type_list:
|
||||
try:
|
||||
# Try to convert string to enum
|
||||
if isinstance(t, str):
|
||||
validated_types.append(SearchItemType(t.lower()))
|
||||
except ValueError:
|
||||
valid_types = [t.value for t in SearchItemType]
|
||||
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
|
||||
# Validate each type against SearchItemType enum
|
||||
validated_types = []
|
||||
for t in type_list:
|
||||
try:
|
||||
# Try to convert string to enum
|
||||
if isinstance(t, str):
|
||||
validated_types.append(SearchItemType(t.lower()))
|
||||
except ValueError:
|
||||
valid_types = [t.value for t in SearchItemType]
|
||||
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
|
||||
|
||||
# Add validated types to params
|
||||
params["type"] = [t.value for t in validated_types] # pyright: ignore
|
||||
# Add validated types to params
|
||||
params["type"] = [t.value for t in validated_types] # pyright: ignore
|
||||
|
||||
# Resolve project parameter using the three-tier hierarchy
|
||||
# allow_discovery=True enables Discovery Mode, so a project is not required
|
||||
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
|
||||
# Resolve project parameter using the three-tier hierarchy
|
||||
# allow_discovery=True enables Discovery Mode, so a project is not required
|
||||
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
|
||||
|
||||
if resolved_project is None:
|
||||
# Discovery Mode: Get activity across all projects
|
||||
# Uses plain get_client() since we iterate across all projects (no single project routing)
|
||||
logger.info(
|
||||
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
if resolved_project is None:
|
||||
# Discovery Mode: Get activity across all projects
|
||||
logger.info(
|
||||
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
|
||||
async with get_client() as client:
|
||||
# Get list of all projects
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
@@ -189,77 +181,76 @@ 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),
|
||||
active_projects=active_projects,
|
||||
most_active_project=most_active_project,
|
||||
total_items=total_items,
|
||||
total_entities=total_entities,
|
||||
total_relations=total_relations,
|
||||
total_observations=total_observations,
|
||||
)
|
||||
|
||||
# Build summary stats
|
||||
summary = ActivityStats(
|
||||
total_projects=len(project_list.projects),
|
||||
active_projects=active_projects,
|
||||
most_active_project=most_active_project,
|
||||
total_items=total_items,
|
||||
total_entities=total_entities,
|
||||
total_relations=total_relations,
|
||||
total_observations=total_observations,
|
||||
)
|
||||
# Generate guidance for the assistant
|
||||
guidance_lines = ["\n" + "─" * 40]
|
||||
|
||||
# Generate guidance for the assistant
|
||||
guidance_lines = ["\n" + "─" * 40]
|
||||
if active_projects == 0:
|
||||
# No recent activity
|
||||
guidance_lines.extend(
|
||||
[
|
||||
"No recent activity found in any project.",
|
||||
"Consider: Ask which project to use or if they want to create a new one.",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# At least one project has activity: suggest the most active project.
|
||||
suggested_project = most_active_project or next(
|
||||
(
|
||||
name
|
||||
for name, activity in projects_activity.items()
|
||||
if activity.item_count > 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if suggested_project:
|
||||
suffix = (
|
||||
f"(most active with {most_active_count} items)"
|
||||
if most_active_count > 0
|
||||
else ""
|
||||
)
|
||||
guidance_lines.append(
|
||||
f"Suggested project: '{suggested_project}' {suffix}".strip()
|
||||
)
|
||||
if active_projects == 1:
|
||||
guidance_lines.append(
|
||||
f"Ask user: 'Should I use {suggested_project} for this task?'"
|
||||
)
|
||||
else:
|
||||
guidance_lines.append(
|
||||
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
|
||||
)
|
||||
|
||||
if active_projects == 0:
|
||||
# No recent activity
|
||||
guidance_lines.extend(
|
||||
[
|
||||
"No recent activity found in any project.",
|
||||
"Consider: Ask which project to use or if they want to create a new one.",
|
||||
"",
|
||||
"Session reminder: Remember their project choice throughout this conversation.",
|
||||
]
|
||||
)
|
||||
|
||||
guidance = "\n".join(guidance_lines)
|
||||
|
||||
# Format discovery mode output
|
||||
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
|
||||
|
||||
else:
|
||||
# At least one project has activity: suggest the most active project.
|
||||
suggested_project = most_active_project or next(
|
||||
(name for name, activity in projects_activity.items() if activity.item_count > 0),
|
||||
None,
|
||||
# Project-Specific Mode: Get activity for specific project
|
||||
logger.info(
|
||||
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
if suggested_project:
|
||||
suffix = (
|
||||
f"(most active with {most_active_count} items)" if most_active_count > 0 else ""
|
||||
)
|
||||
guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip())
|
||||
if active_projects == 1:
|
||||
guidance_lines.append(
|
||||
f"Ask user: 'Should I use {suggested_project} for this task?'"
|
||||
)
|
||||
else:
|
||||
guidance_lines.append(
|
||||
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
|
||||
)
|
||||
|
||||
guidance_lines.extend(
|
||||
[
|
||||
"",
|
||||
"Session reminder: Remember their project choice throughout this conversation.",
|
||||
]
|
||||
)
|
||||
active_project = await get_active_project(client, resolved_project, context)
|
||||
|
||||
guidance = "\n".join(guidance_lines)
|
||||
|
||||
# Format discovery mode output
|
||||
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
|
||||
|
||||
else:
|
||||
# Project-Specific Mode: Get activity for specific project
|
||||
# Uses get_project_client() for per-project routing (local vs cloud)
|
||||
logger.info(
|
||||
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
|
||||
async with get_project_client(resolved_project, workspace, context) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/memory/recent",
|
||||
@@ -267,9 +258,6 @@ 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)
|
||||
|
||||
@@ -327,29 +315,6 @@ 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:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Release notes MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@mcp.tool("release_notes")
|
||||
def release_notes() -> str:
|
||||
"""Return the latest product release notes for optional user review."""
|
||||
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
|
||||
return content_path.read_text(encoding="utf-8")
|
||||
@@ -1,344 +0,0 @@
|
||||
"""Schema tools for Basic Memory MCP server.
|
||||
|
||||
Provides tools for schema validation, inference, and drift detection through the MCP protocol.
|
||||
These tools call the schema API endpoints via the typed SchemaClient.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
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(
|
||||
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.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
|
||||
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's entity field)
|
||||
4. No schema (no validation)
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
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.
|
||||
project: Project name. Optional -- server will resolve.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
ValidationReport with per-note results, or error guidance string
|
||||
|
||||
Examples:
|
||||
# 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(note_type="person", project="my-research")
|
||||
"""
|
||||
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"note_type={note_type} identifier={identifier}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.validate(
|
||||
entity_type=note_type,
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_validate project={active_project.name} "
|
||||
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:
|
||||
logger.error(f"Schema validation failed: {e}, project: {active_project.name}")
|
||||
return (
|
||||
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 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"
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Analyze existing notes and suggest a Picoschema definition.",
|
||||
)
|
||||
async def schema_infer(
|
||||
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.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema
|
||||
YAML that can be saved as a schema note.
|
||||
|
||||
Frequency thresholds:
|
||||
- 95%+ present -> required field
|
||||
- threshold+ present -> optional field
|
||||
- Below threshold -> excluded (but noted)
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
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.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
InferenceReport with frequency data and suggested schema, or error string
|
||||
|
||||
Examples:
|
||||
# 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")
|
||||
"""
|
||||
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"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(note_type, threshold=threshold)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_infer project={active_project.name} "
|
||||
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 type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\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"
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Detect drift between a schema definition and actual note usage.",
|
||||
)
|
||||
async def schema_diff(
|
||||
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 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).
|
||||
|
||||
Useful for evolving schemas as your knowledge base grows -- run
|
||||
periodically to see if your schema still matches reality.
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
||||
If project unknown, use list_memory_projects() first.
|
||||
|
||||
Args:
|
||||
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.
|
||||
|
||||
Returns:
|
||||
DriftReport with new fields, dropped fields, and cardinality changes,
|
||||
or error guidance string
|
||||
|
||||
Examples:
|
||||
# Check drift for person schema
|
||||
schema_diff("person")
|
||||
|
||||
# Check drift in a specific project
|
||||
schema_diff("person", project="my-research")
|
||||
"""
|
||||
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} 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(note_type)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=schema_diff project={active_project.name} "
|
||||
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 type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\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"
|
||||
)
|
||||
@@ -1,32 +1,15 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
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.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.search import (
|
||||
SearchItemType,
|
||||
SearchQuery,
|
||||
SearchResponse,
|
||||
SearchRetrievalMode,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
def _format_search_error_response(
|
||||
@@ -34,35 +17,6 @@ def _format_search_error_response(
|
||||
) -> str:
|
||||
"""Format helpful error responses for search failures that guide users to successful searches."""
|
||||
|
||||
# Semantic config/dependency errors
|
||||
if "semantic search is disabled" in error_message.lower():
|
||||
return dedent(f"""
|
||||
# Search Failed - Semantic Search Disabled
|
||||
|
||||
You requested `{search_type}` search for query '{query}', but semantic search is disabled.
|
||||
|
||||
## How to enable
|
||||
1. Set `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true`
|
||||
2. Restart the Basic Memory server/process
|
||||
|
||||
## Alternative now
|
||||
- Run FTS search instead:
|
||||
`search_notes("{project}", "{query}", search_type="text")`
|
||||
""").strip()
|
||||
|
||||
if "pip install" in error_message.lower() and "semantic" in error_message.lower():
|
||||
return dedent(f"""
|
||||
# Search Failed - Semantic Dependencies Missing
|
||||
|
||||
Semantic retrieval is enabled but required packages are not installed.
|
||||
|
||||
## Fix
|
||||
1. Install semantic extras: `pip install 'basic-memory[semantic]'`
|
||||
2. Restart Basic Memory
|
||||
3. Retry your query:
|
||||
`search_notes("{project}", "{query}", search_type="{search_type}")`
|
||||
""").strip()
|
||||
|
||||
# FTS5 syntax errors
|
||||
if "syntax error" in error_message.lower() or "fts5" in error_message.lower():
|
||||
clean_query = (
|
||||
@@ -243,26 +197,21 @@ Error searching for '{query}': {error_message}
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
# 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["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 | dict | str:
|
||||
) -> SearchResponse | 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,
|
||||
@@ -280,8 +229,7 @@ async def search_notes(
|
||||
- `search_notes("work-docs", "'exact phrase'")` - Search for exact phrase match
|
||||
|
||||
### Advanced Boolean Searches
|
||||
- `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 term2")` - Find content with both terms (implicit AND)
|
||||
- `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
|
||||
@@ -295,8 +243,7 @@ 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")` - Text search (default; auto-upgrades
|
||||
to hybrid when semantic search is enabled)
|
||||
- `search_notes("research", "keyword", search_type="text")` - Full-text search (default)
|
||||
|
||||
### Filtering Options
|
||||
- `search_notes("my-project", "query", types=["entity"])` - Search only entities
|
||||
@@ -338,20 +285,13 @@ async def search_notes(
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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", "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.
|
||||
search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text")
|
||||
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:
|
||||
@@ -360,7 +300,6 @@ 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")
|
||||
@@ -421,62 +360,41 @@ async def search_notes(
|
||||
types = types or []
|
||||
entity_types = entity_types or []
|
||||
|
||||
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"
|
||||
# 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 == "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_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
|
||||
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
|
||||
|
||||
@@ -496,9 +414,6 @@ 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 == "json":
|
||||
return result.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -513,7 +428,6 @@ 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,
|
||||
@@ -543,7 +457,8 @@ async def search_by_metadata(
|
||||
page = (offset // limit) + 1
|
||||
offset_within_page = offset % limit
|
||||
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
logger.info(
|
||||
f"Structured search in project {active_project.name} filters={filters} limit={limit} offset={offset}"
|
||||
)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Embedded UI tools using the MCP-UI Python SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastmcp import Context
|
||||
from mcp.types import ContentBlock, TextContent
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.ui.sdk import MissingMCPUIServerError, build_embedded_ui_resource
|
||||
|
||||
|
||||
def _text_block(message: str) -> List[ContentBlock]:
|
||||
return [TextContent(type="text", text=message)]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def search_notes_ui(
|
||||
query: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "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,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a search results UI as an embedded MCP-UI resource."""
|
||||
result = await search_notes.fn(
|
||||
query=query,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
output_format="json",
|
||||
types=types,
|
||||
entity_types=entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if isinstance(result, str):
|
||||
return _text_block(result)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": result,
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui-sdk",
|
||||
html_filename="search-results-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "540px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def read_note_ui(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a note preview UI as an embedded MCP-UI resource."""
|
||||
content = await read_note.fn(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="text",
|
||||
context=context,
|
||||
)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"identifier": identifier,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": content,
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui-sdk",
|
||||
html_filename="note-preview-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "640px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
@@ -16,7 +16,6 @@ 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,
|
||||
@@ -58,14 +57,7 @@ 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=identifier,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
context=context,
|
||||
)
|
||||
content = await read_note.fn(identifier, project, page, page_size, context)
|
||||
|
||||
# Check if this is an error message (note not found)
|
||||
if "# Note Not Found" in content:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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,10 +1,11 @@
|
||||
"""Write note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import List, Union, Optional, Literal
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -22,21 +23,17 @@ 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 | dict:
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
Creates or updates a markdown note with semantic observations and relations.
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects using a unified priority chain (same in local and cloud modes):
|
||||
Single Project Mode → project parameter → default project.
|
||||
Uses default project automatically. Specify `project` parameter to target a different project.
|
||||
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
||||
If project unknown, use list_memory_projects() or recent_activity() first.
|
||||
|
||||
The content can include semantic observations and relations using markdown syntax:
|
||||
|
||||
@@ -70,11 +67,6 @@ 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:
|
||||
@@ -87,7 +79,12 @@ async def write_note(
|
||||
- Session tracking metadata for project awareness
|
||||
|
||||
Examples:
|
||||
# Create a simple note (uses default project automatically)
|
||||
# Assistant flow when project is unknown
|
||||
# 1. list_memory_projects() -> Ask user which project
|
||||
# 2. User: "Use my-research"
|
||||
# 3. write_note(...) and remember "my-research" for session
|
||||
|
||||
# Create a simple note
|
||||
write_note(
|
||||
project="my-research",
|
||||
title="Meeting Notes",
|
||||
@@ -113,29 +110,18 @@ 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, workspace, context) as (client, active_project):
|
||||
async with get_client() as client:
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
|
||||
f"MCP tool call tool=write_note project={project} directory={directory}, title={title}, tags={tags}"
|
||||
)
|
||||
|
||||
# Get and validate the project (supports optional project parameter)
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Normalize "/" to empty string for root directory (must happen before validation)
|
||||
if directory == "/":
|
||||
directory = ""
|
||||
@@ -148,35 +134,19 @@ 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)
|
||||
|
||||
# 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
|
||||
|
||||
# Create the entity request
|
||||
metadata = {"tags": tag_list} if tag_list else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
entity_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=entity_metadata or None,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
@@ -258,14 +228,5 @@ 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,5 +0,0 @@
|
||||
"""UI helpers for MCP Apps resources."""
|
||||
|
||||
from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html
|
||||
|
||||
__all__ = ["get_ui_variant", "load_html", "load_variant_html"]
|
||||
@@ -1,176 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview - MCP UI</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fdf2f2;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a1a1d;
|
||||
--muted: #6b6770;
|
||||
--border: #ecd7dd;
|
||||
--accent: #b91c1c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #fff7f7 0%, #f7ecec 60%, #f0e3e4 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fee2e2;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="badge">mcp-ui style</div>
|
||||
</div>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,160 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f1ea;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d1d1f;
|
||||
--muted: #6d6d6d;
|
||||
--accent: #0f766e;
|
||||
--border: #e1d7cc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
background: linear-gradient(140deg, #f7f3ed, #efe8dd);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user