Compare commits

..

5 Commits

Author SHA1 Message Date
phernandez 2d65d1b6bb Support typed search facet filters 2026-05-04 15:24:42 -05:00
phernandez e1adf10f1b test(api): cover postgres search count syntax errors
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-04 14:54:57 -05:00
phernandez fa4845119d fix(api): avoid semantic count searches
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-04 14:21:27 -05:00
phernandez 3589e21278 fix(api): handle blank search text
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-04 13:35:13 -05:00
phernandez 7f9fc80e27 Add exact totals to search pagination
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-04 12:57:39 -05:00
118 changed files with 2694 additions and 9935 deletions
+1 -4
View File
@@ -10,9 +10,6 @@ on:
# - "src/**/*.js"
# - "src/**/*.jsx"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude-review:
# Only run for organization members and collaborators
@@ -30,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1
+2 -5
View File
@@ -4,9 +4,6 @@ on:
issues:
types: [opened]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
triage:
runs-on: ubuntu-latest
@@ -15,7 +12,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -71,4 +68,4 @@ jobs:
Read the issue carefully and provide helpful triage with appropriate labels.
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
+2 -4
View File
@@ -12,9 +12,6 @@ on:
pull_request_target:
types: [opened, synchronize]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude:
if: |
@@ -44,7 +41,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
# For pull_request_target, checkout the PR head to review the actual changes
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
@@ -68,3 +65,4 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
+3 -6
View File
@@ -5,9 +5,6 @@ on:
branches: [main]
workflow_dispatch: # Allow manual triggering
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
dev-release:
runs-on: ubuntu-latest
@@ -16,12 +13,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -53,4 +50,4 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true # Don't fail if version already exists
skip-existing: true # Don't fail if version already exists
+6 -6
View File
@@ -9,7 +9,6 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: basicmachines-co/basic-memory
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
docker:
@@ -20,17 +19,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -38,7 +37,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -49,7 +48,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
@@ -59,3 +58,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -5
View File
@@ -7,14 +7,11 @@ on:
- edited
- synchronize
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -41,4 +38,4 @@ jobs:
deps
installer
# Allow breaking changes (needs "!" after type/scope)
requireScopeForBreakingChange: true
requireScopeForBreakingChange: true
+22 -62
View File
@@ -5,9 +5,6 @@ on:
tags:
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release:
runs-on: ubuntu-latest
@@ -16,12 +13,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -42,7 +39,7 @@ jobs:
echo "Build completed successfully"
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.whl
@@ -63,63 +60,26 @@ jobs:
# Only run for stable releases (not dev, beta, or rc versions)
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
permissions:
contents: read
contents: write
actions: read
steps:
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
# with the bearer token and expects a 302 redirect. GitHub now returns
# 303 on that endpoint when authenticated, which the action treats as a
# fatal error. Re-implementing the bump as plain git+sed keeps the same
# contract (update url + sha256, commit, push) with no third-party action.
- name: Update Homebrew formula
uses: mislav/bump-homebrew-formula-action@v3
with:
# Formula name in homebrew-basic-memory repo
formula-name: basic-memory
# The tap repository
homebrew-tap: basicmachines-co/homebrew-basic-memory
# Base branch of the tap repository
base-branch: main
# Download URL will be automatically constructed from the tag
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
# Commit message for the formula update
commit-message: |
{{formulaName}} {{version}}
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
env:
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
REF: ${{ github.ref_name }}
REPO: ${{ github.repository }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Personal Access Token with repo scope for homebrew-basic-memory repo
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
VERSION="${REF#v}"
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
echo "::group::Compute tarball sha256"
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
test -n "$SHA256"
echo "sha256: $SHA256"
echo "::endgroup::"
echo "::group::Clone tap"
git clone \
--depth 1 \
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
tap
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "::endgroup::"
echo "::group::Patch Formula/basic-memory.rb"
# Pipe-delimited sed because the URL contains slashes. The Formula
# only has one `url` and one `sha256` directive, so a first-match
# replacement is unambiguous. POSIX character classes ([[:space:]])
# keep this portable across BSD and GNU sed.
sed -i -E \
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
Formula/basic-memory.rb
git --no-pager diff Formula/basic-memory.rb
echo "::endgroup::"
if git diff --quiet Formula/basic-memory.rb; then
echo "Formula already at ${REF}; nothing to do."
exit 0
fi
echo "::group::Commit & push"
git add Formula/basic-memory.rb
git commit -m "basic-memory ${VERSION}
Created by ${RUN_URL}"
git push origin HEAD:main
echo "::endgroup::"
+20 -23
View File
@@ -11,9 +11,6 @@ on:
# Outcome: each branch push runs the test suite once, including PR updates.
push:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
static-checks:
name: Static Checks (Python 3.12)
@@ -21,12 +18,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: "3.12"
cache: "pip"
@@ -35,7 +32,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -55,7 +52,7 @@ jobs:
test-sqlite-unit:
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -71,12 +68,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -85,7 +82,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -117,12 +114,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -131,7 +128,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -147,7 +144,7 @@ jobs:
test-postgres-unit:
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 60
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -174,12 +171,12 @@ jobs:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -188,7 +185,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -231,12 +228,12 @@ jobs:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -245,7 +242,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -265,12 +262,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: "3.12"
cache: "pip"
@@ -279,7 +276,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
-9
View File
@@ -56,15 +56,6 @@ Run `just test-smoke` when you specifically need the MCP smoke flow.
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
### PR CI Gate
Before opening or updating a PR, run the checks that mirror the common required CI failures:
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`.
### Test Structure
- `tests/` - Unit tests for individual components (mocked, fast)
+1 -132
View File
@@ -1,137 +1,6 @@
# CHANGELOG
## v0.21.1 (2026-05-16)
CI-only release. No user-facing changes.
### Maintenance
- **#833**: Replace `mislav/bump-homebrew-formula-action` with an inline
bash bump step. The action's `resolveRedirect` HEAD-requests
`api.github.com /repos/.../tarball/<ref>` expecting HTTP 302; GitHub now
returns 303 for authenticated requests on that endpoint, which broke
the v0.21.0 Homebrew job and required a manual tap bump. The inline
step does the same work (curl + sha256sum, clone tap, sed-bump
`url`/`sha256`, commit, push) without any third-party dependency.
## v0.21.0 (2026-05-16)
Workspace-aware everywhere: every MCP tool and CLI command now routes through the
same workspace/project model, the search and sync paths are noticeably faster,
and a handful of long-standing parsing and routing footguns are gone.
### Breaking Changes
- Relation parsing no longer treats unquoted multi-word text before a wikilink as a
custom relation type. Use single-token relation types like `relates_to [[Target]]`,
or quote multi-word relation types like `"relates to" [[Target]]` or
`'relates to' [[Target]]`.
- Bare list wikilinks like `- [[Target]]` now index as `links_to`.
- Prose list items like `- some other thing [[Target]]` now index as `links_to`.
- To preserve existing multi-word relation types on re-sync, quote them before upgrading.
- See **#824**.
### Features
- **#816**: `bm orphan` CLI command surfaces entities whose underlying markdown
files are gone, with a flag to clean them out.
- **#789**: Create projects directly by cloud workspace slug from MCP
(`create_memory_project(workspace=...)`).
- **#757**: Discover projects across every accessible cloud workspace in MCP's
project list — no more per-workspace blind spots.
- **#766**: MCP tools accept training-data-friendly parameter aliases
(`q`/`search`/`text` for `query`, etc.) so models reach for them naturally.
- **#776**: `bm db reset` refuses to run while a `basic-memory mcp` process is
alive, so resets can no longer corrupt an open session.
- **#791**: Search responses include result totals so pagers can stop guessing.
- **#719**: Cloud `note_content` tenant schema primitive lands on the backend.
- **#715**: `bm project add` accepts a `--visibility` flag for cloud projects.
### Bug Fixes
#### Search and recent activity
- **#832**: SQLite project deletion now sweeps `search_index`,
`search_vector_chunks`, and `search_vector_embeddings`, so a project that
reuses a recycled auto-increment id can't inherit the previous tenant's content.
- **#812**: `recent_activity` orders and filters by `updated_at`, so edits bubble
to the top instead of staying pinned to creation time.
- **#807**: Multi-project `search_notes` is opt-in (`search_all_projects=True`);
default search stays scoped to the resolved project.
- **#785**: `recent_activity` caps responses and emits an explicit truncation
footer instead of silently dropping rows.
- **#713**: Eliminated an N+1 query in search result hydration.
#### Workspace / project routing
- **#822**: `bm project list` now includes projects from every workspace, not
just the current one.
- **#813**, **#808**, **#806**, **#803**, **#801**, **#795**, **#790**, **#778**,
**#777**, **#722**, **#712**, **#704**: Workspace-qualified permalink routing
is centralized and applied consistently across `edit_note`, `delete_project`,
`build_context`, `memory://` URLs, factory-mode project listing, cloud uploads,
and the API client.
#### Sync
- **#827**: `rclone bisync` filters from `.bmignore` are preserved across syncs.
- **#815**: Watch service ignores hidden paths relative to the watched project,
not just relative to `$HOME`.
- **#814**: `scan` subprocesses no longer go through the shell, avoiding quoting
issues with paths that contain special characters.
- **#759**: Watch service stays inside `--project` scope.
- **#746**: Canonical markdown is preserved during single-file sync.
#### Parsing
- **#796**: Picoschema modifier descriptions (`field?(modifier, description)`)
parse correctly.
- **#769**: Obsidian callout blocks are skipped by the observation parser
instead of being mis-extracted as observations.
#### CLI
- **#775**: `bm project set-cloud` / `set-local` cleans up local DB state for
the affected project.
- **#773**: `bm cloud logout` clears `default_workspace`.
- **#780**: `bm cloud setup` hint points at `bm cloud sync-setup`.
- **#734**: `bm project info` shows cloud index freshness.
- **#718**: Private cloud projects display under their `display_name` instead of
raw UUID.
- **#768**: `read_note` / `view_note` drop no-op pagination params from their
signatures.
#### Stability
- **#774**: `sqlite-vec` failures during init degrade gracefully to keyword-only
search instead of crashing startup.
- **#733**: Delete-vector and cloud-sync cleanup is now consistent.
- **#702**: Race conditions in concurrent `delete_entity` are resolved.
- **#724**: `external_id` is preserved when entities are re-upserted during a
re-index.
- **#728**: Vector init no longer issues runtime `ALTER TABLE`.
- **#744**: `BASIC_MEMORY_CONFIG_DIR` is honored across remaining call sites.
- **#743**: FastEmbed cache lives under the data dir instead of `/tmp`.
- **#752**: Cloud projects report `source=cloud` in factory mode.
- Stripped null bytes from markdown content before DB insert.
- Allowed long `relation_type` values in API responses.
#### Installer
- **#772**: Docker-compose config volume mounts under the `appuser` home.
- **#695**: Bumped `brew outdated` timeout from 15s to 60s.
### Performance
- **#828**: CLI startup no longer pulls in the local ASGI FastAPI app when it
isn't needed.
- **#751**, **#726**, **#717**, **#714**, single-file/batch indexing: marked
speedups on the sync hot path; unchanged markdown is skipped entirely.
- **#731**, **#723**: Vector sync is faster on both backends, with tuned
fastembed defaults.
### Maintenance
- **#825**: Dependency refresh + security hardening.
- Updated to `fastmcp` 3.3.1.
- **#754**: Removed in-house telemetry wrappers in favor of direct `logfire`
usage.
- **#736**: `ty` is now the default typechecker.
- **#771**, **#770**, **#716**: New regression guards for vector-row cleanup,
long relation types, and recent activity hydration.
## Unreleased
## v0.20.3 (2026-03-26)
+577 -453
View File
File diff suppressed because it is too large Load Diff
+2 -67
View File
@@ -8,71 +8,6 @@
## Reporting a Vulnerability
If you find a vulnerability, please contact hello@basicmachines.co.
Use this section to tell people how to report a vulnerability.
Please do not open a public GitHub issue for security vulnerabilities. We aim
to respond within 72 hours and will coordinate a fix and disclosure timeline
with you.
## Threat Model
Basic Memory is a local-first MCP server that reads and writes markdown files
inside configured project directories. It runs on your machine with your user
permissions, so local configuration deserves the same care as any other
developer tool that can access your files.
### What Basic Memory Controls
- Filesystem-touching tools validate paths against the configured project root
with `validate_project_path()`, resolved paths, and `Path.is_relative_to()`.
Path traversal attempts such as `../../etc/passwd` are blocked at this layer.
- Scan optimizations in `sync_service.py` call `find` through
`asyncio.create_subprocess_exec()` with explicit argument lists. Project paths
are passed as data, not interpolated into shell strings.
- Auto-update code uses hardcoded commands, list-form arguments, and
`stdin=DEVNULL`. User-controlled strings do not reach a shell there.
### MCP Client-Side Risk
Recent MCP ecosystem research has highlighted a client-side pattern where an
MCP host can be configured to run arbitrary commands as "servers." That risk is
in the host configuration, not in notes or Basic Memory tool input.
The recommended Basic Memory MCP configuration uses a known command with
explicit arguments:
```json
{
"mcpServers": {
"basic-memory": {
"command": "uvx",
"args": ["basic-memory", "mcp"]
}
}
}
```
Only add MCP server entries from sources you trust. Avoid inline shell scripts
or command strings copied from untrusted sources. Treat third-party MCP server
configuration with the same scrutiny as any locally executed program.
Related ecosystem context:
- OX Security: The Mother of All AI Supply Chains
- CSO Online: RCE by design: MCP architectural choice haunts AI agent ecosystem
### Out Of Scope
- Basic Memory does not execute note content as code. Notes are returned as
data to the LLM.
- Basic Memory does not open network ports by default. The MCP server uses
stdio; the optional REST API is intended for localhost use.
- Basic Memory is designed for single-user local knowledge bases and does not
implement access controls between operating-system users.
## Secure Configuration Checklist
- MCP config `command` points to `uvx` or a trusted binary, not a shell string.
- Project paths in Basic Memory config come from trusted local configuration.
- If exposing the REST API, bind it only to localhost.
- Review any third-party MCP servers before adding them to your host config.
If you find a vulnerability, please contact hello@basicmachines.co
+2 -5
View File
@@ -1,8 +1,5 @@
# Docker Compose configuration for Basic Memory with PostgreSQL
# Use this for local development and testing with Postgres backend.
#
# The Postgres backend requires the pgvector extension (semantic search).
# This image bundles pgvector; plain postgres:17 will not work for vector search.
# Use this for local development and testing with Postgres backend
#
# Usage:
# docker-compose -f docker-compose-postgres.yml up -d
@@ -10,7 +7,7 @@
services:
postgres:
image: pgvector/pgvector:pg17
image: postgres:17
container_name: basic-memory-postgres
environment:
# Local development/test credentials - NOT for production
+7 -25
View File
@@ -106,7 +106,7 @@ The parser excludes these list item patterns:
|---------|---------|--------|
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
| Markdown links | `- [text](url)` | URL link syntax |
| Bare wiki links | `- [[Target]]` | Treated as a `links_to` relation instead |
| 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`.
@@ -116,65 +116,47 @@ 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. Unquoted
relation types are single tokens. Quote relation types that contain spaces.
Written as list items with a relation type and a `[[wiki link]]` target.
**Syntax:**
```
- relation_type [[Target Entity]] (context)
- "multi word relation type" [[Target Entity]] (context)
- 'multi word relation type' [[Target Entity]] (context)
```
| Part | Required | Description |
|------|----------|-------------|
| `relation_type` | Yes | Single unquoted token before `[[`, or quoted text for multi-word labels. |
| `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
Explicit relations:
```markdown
- implements [[Search Design]]
- depends_on [[Database Schema]]
- works_at [[Y Combinator]] (co-founder)
- "based on" [[Customer Interview]]
- 'in response to' [[Incident Review]]
```
Bare wiki links and prose list items create implicit `links_to` relations:
```markdown
- [[Some Entity]]
- some other thing [[Some Entity]]
```
Both examples above create `links_to [[Some Entity]]`. Use quotes when the words before
`[[` are meant to be a multi-word relation type.
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 single-token text or quoted text works as a relation type. These are conventions,
not a fixed set.
Any text works as a relation type. These are conventions, not a fixed set.
### Inline References
Wiki links appearing in regular prose create implicit `links_to` relations. This includes
list items that do not match the explicit relation grammar above.
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]].
- We should revisit [[Search Design]] after the API changes.
```
This creates three relations: `links_to [[Core Design]]`, `links_to [[Utility Functions]]`,
and `links_to [[Search Design]]`.
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
### Forward References
+20 -91
View File
@@ -94,18 +94,13 @@ bm cloud setup
```
**What this does:**
1. Installs rclone with a supported package manager (if needed)
1. Installs rclone automatically (if needed)
2. Fetches your tenant information from cloud
3. Generates scoped S3 credentials for sync
4. Configures single rclone remote: `basic-memory-cloud`
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
install scripts with `sudo`; if no supported package manager is found, the CLI prints
manual install instructions.
### 3. Add Projects with Sync
Create projects with optional local sync paths:
@@ -490,9 +485,6 @@ 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`.
On POSIX systems, Basic Memory writes `~/.basic-memory/` as user-private (`0700`) and
`config.json` as user-read/write only (`0600`). Treat this config file as a credential
file when an API key is saved.
### Setting Project Modes
@@ -582,62 +574,33 @@ bm cloud logout
**Default patterns:**
```gitignore
# Hidden files and directories
.*
# Basic Memory internals
*.db
*.db-shm
*.db-wal
config.json
# Version control
.git
.svn
.git/**
# Python
__pycache__
__pycache__/**
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
*.egg-info
.tox
.mypy_cache
.ruff_cache
# Virtual environments
.venv
venv
env
.env
.venv/**
venv/**
# Node.js
node_modules
node_modules/**
# Build artifacts
build
dist
.cache
# IDE
.idea
.vscode
# Basic Memory internals
memory.db/**
memory.db-shm/**
memory.db-wal/**
config.json/**
watch-status.json/**
.bmignore.rclone/**
# OS files
.DS_Store
Thumbs.db
desktop.ini
.DS_Store/**
Thumbs.db/**
# Obsidian
.obsidian
# Temporary files
*.tmp
*.swp
*.swo
*~
# Environment files
.env/**
.env.local/**
```
**How it works:**
@@ -646,11 +609,6 @@ desktop.ini
3. Rclone uses filters during sync
4. Same patterns used by all projects
During conversion, file patterns exclude the direct match and recursive contents.
For example, `config.json` becomes both `- config.json` and `- config.json/**`,
while `.*` becomes both `- .*` and `- .*/**`. Directory-only patterns keep
their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
**Customizing:**
```bash
@@ -658,7 +616,7 @@ their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
code ~/.basic-memory/.bmignore
# Add custom patterns
echo "*.tmp" >> ~/.basic-memory/.bmignore
echo "*.tmp/**" >> ~/.basic-memory/.bmignore
# Next sync uses updated patterns
bm project bisync --name research
@@ -666,33 +624,6 @@ bm project bisync --name research
## Troubleshooting
### Rclone Setup Cannot Install Automatically
**Problem:** `bm cloud setup` cannot find a supported package manager, or package-manager
installation fails.
**Explanation:** The CLI avoids remote privileged install scripts. It only invokes known
package managers and otherwise asks you to install rclone manually.
**Solution:** Install rclone with your OS package manager, then rerun setup:
```bash
# macOS
brew install rclone
# Debian/Ubuntu
sudo apt install rclone
# Fedora
sudo dnf install rclone
# Arch
sudo pacman -S rclone
# After rclone is on PATH
bm cloud setup
```
### Authentication Issues
**Problem:** "Authentication failed" or "Invalid token"
@@ -828,10 +759,8 @@ If instance is down, wait a few minutes and retry.
- **Authentication**: OAuth 2.1 with PKCE flow
- **Tokens**: Stored securely in `~/.basic-memory/basic-memory-cloud.json`
- **API keys**: Stored in `~/.basic-memory/config.json`, which is written with private file permissions on POSIX systems
- **Transport**: All data encrypted in transit (HTTPS)
- **Credentials**: Scoped S3 credentials (read-write to your tenant only)
- **Rclone setup**: Uses package managers or manual instructions; no remote privileged install-script fallback
- **Isolation**: Your data isolated from other tenants
- **Ignore patterns**: Sensitive files automatically excluded via `.bmignore`
@@ -856,7 +785,7 @@ bm cloud create-key <name> # Create API key via cloud API (requires OAuth login
### Setup
```bash
bm cloud setup # Install rclone via package manager and configure credentials
bm cloud setup # Install rclone and configure credentials
```
### Project Management
-1
View File
@@ -263,7 +263,6 @@ The sqlite-vec extension is loaded per-connection. Vector tables are created laz
### Postgres (cloud)
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
- **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
+2 -3
View File
@@ -25,12 +25,11 @@ dependencies = [
"unidecode>=1.3.8",
"dateparser>=1.2.0",
"watchfiles>=1.0.4",
"fastapi[standard]>=0.136.1",
"fastapi[standard]>=0.115.8",
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
# Keep FastMCP pinned until each minor upgrade passes the MCP transport matrix.
"fastmcp==3.3.1",
"fastmcp>=3.0.1,<4",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
+2 -2
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.21.1",
"version": "0.20.3",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.21.1",
"version": "0.20.3",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.21.1"
__version__ = "0.20.3"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -1,86 +0,0 @@
"""Remove orphaned search rows whose project was already deleted.
Revision ID: n7i8j9k0l1m2
Revises: m6h7i8j9k0l1
Create Date: 2026-05-15 18:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
revision: str = "n7i8j9k0l1m2"
down_revision: Union[str, None] = "m6h7i8j9k0l1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(connection, table_name: str) -> bool:
"""Inspector-based table check, dialect agnostic.
Trigger: SQLite creates search_index as an FTS5 virtual table at runtime
via SearchRepository.init_search_index, not through Alembic, so fresh
installs hit this migration before the table exists.
Why: a blind DELETE against a missing table fails the whole upgrade.
Outcome: callers skip the sweep when the table isn't present yet — the
runtime-created table on a fresh DB has no orphans to clean.
"""
return table_name in inspect(connection).get_table_names()
def upgrade() -> None:
"""Purge orphaned search rows left over from prior project deletions.
Trigger: project deletion on SQLite never removed the derived FTS rows,
because the FTS5 virtual table can't carry a foreign key. The leak shows
up in two shapes:
1. project_id no longer exists in `project` (deleted project, id never
reused).
2. project_id still exists but `entity_id` no longer exists in `entity`
— auto-increment handed the id to a brand-new project and the FTS
rows from the deleted predecessor masquerade as the new tenant's data.
Why: search_index.project_id is the only scope predicate the search
repository applies, so leftover rows surface under the wrong project on
every search.
Outcome: a one-time sweep deletes both shapes, from the FTS index and
from search_vector_chunks. Postgres already cascaded on FK delete, so
these statements are no-ops there.
"""
connection = op.get_bind()
if _table_exists(connection, "search_index"):
op.execute(
"""
DELETE FROM search_index
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_index
WHERE entity_id IS NOT NULL
AND entity_id NOT IN (SELECT id FROM entity)
"""
)
if _table_exists(connection, "search_vector_chunks"):
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE entity_id NOT IN (SELECT id FROM entity)
"""
)
def downgrade() -> None:
"""No-op: orphan rows cannot be reconstructed."""
pass
@@ -10,7 +10,6 @@ import logging
from fastapi import APIRouter, Form, HTTPException, UploadFile, status, Path
from basic_memory.deps import (
AppConfigDep,
ChatGPTImporterV2ExternalDep,
ClaudeConversationsImporterV2ExternalDep,
ClaudeProjectsImporterV2ExternalDep,
@@ -28,21 +27,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/import", tags=["import-v2"])
async def read_import_upload(file: UploadFile, max_bytes: int) -> bytes:
"""Read an import upload with a hard cap before JSON parsing."""
content = await file.read(max_bytes + 1)
if len(content) > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=f"Import file exceeds maximum size of {max_bytes} bytes.",
)
return content
@router.post("/chatgpt", response_model=ChatImportResult)
async def import_chatgpt(
importer: ChatGPTImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -62,13 +49,12 @@ async def import_chatgpt(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/claude/conversations", response_model=ChatImportResult)
async def import_claude_conversations(
importer: ClaudeConversationsImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -88,13 +74,12 @@ async def import_claude_conversations(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude conversations for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/claude/projects", response_model=ProjectImportResult)
async def import_claude_projects(
importer: ClaudeProjectsImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("projects"),
@@ -114,13 +99,12 @@ async def import_claude_projects(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude projects for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/memory-json", response_model=EntityImportResult)
async def import_memory_json(
importer: MemoryJsonImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -142,7 +126,7 @@ async def import_memory_json(
logger.info(f"V2 Importing memory.json for project {project_id}")
try:
file_data = []
file_bytes = await read_import_upload(file, config.import_upload_max_bytes)
file_bytes = await file.read()
file_str = file_bytes.decode("utf-8")
for line in file_str.splitlines():
json_data = json.loads(line)
@@ -154,8 +138,6 @@ async def import_memory_json(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.error_message or "Import failed",
)
except HTTPException:
raise
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
@@ -165,16 +147,13 @@ async def import_memory_json(
return result
async def import_file(
importer: Importer, file: UploadFile, destination_directory: str, max_bytes: int
):
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
"""Helper function to import a file using an importer instance.
Args:
importer: The importer instance to use
file: The file to import
destination_directory: Destination directory for imported content
max_bytes: Maximum upload size in bytes; raises HTTP 413 if exceeded
Returns:
Import result from the importer
@@ -184,8 +163,7 @@ async def import_file(
"""
try:
# Process file
upload_bytes = await read_import_upload(file, max_bytes)
json_data = json.loads(upload_bytes)
json_data = json.load(file.file)
result = await importer.import_data(json_data, destination_directory)
if not result.success: # pragma: no cover
raise HTTPException(
@@ -195,8 +173,6 @@ async def import_file(
return result
except HTTPException:
raise
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
@@ -38,7 +38,6 @@ from basic_memory.schemas.v2 import (
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
OrphanEntitiesResponse,
)
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
@@ -111,38 +110,6 @@ async def get_graph(
return GraphResponse(nodes=nodes, edges=edges)
## Orphan entities endpoint
@router.get("/orphans", response_model=OrphanEntitiesResponse)
async def get_orphan_entities(
project_id: ProjectExternalIdPathDep,
entity_repository: EntityRepositoryV2ExternalDep,
) -> OrphanEntitiesResponse:
"""Return entities that have no incoming or outgoing relations."""
with logfire.span(
"api.request.knowledge.get_orphans",
entrypoint="api",
domain="knowledge",
action="get_orphans",
):
logger.info("API v2 request: get_orphan_entities")
entities = await entity_repository.find_without_relations()
nodes = [
GraphNode(
external_id=entity.external_id,
title=entity.title,
note_type=entity.note_type,
file_path=entity.file_path,
)
for entity in entities
]
logger.info(f"API v2 response: {len(nodes)} orphan entities")
return OrphanEntitiesResponse(entities=nodes, total=len(nodes))
## Resolution endpoint
@@ -11,7 +11,7 @@ Key improvements:
"""
import os
from typing import Literal, Optional
from typing import Optional
from fastapi import APIRouter, HTTPException, Body, Query, Path
from loguru import logger
@@ -25,8 +25,6 @@ from basic_memory.deps import (
ProjectExternalIdPathDep,
)
from basic_memory.schemas import SyncReportResponse
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas.project_info import (
ProjectItem,
ProjectList,
@@ -38,71 +36,6 @@ from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveRespons
from basic_memory.utils import normalize_project_path, generate_permalink
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
ProjectResolveMethod = Literal["external_id", "name", "permalink"]
def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]:
"""Split ``<workspace>/<project>`` identifiers while preserving plain project names."""
cleaned = identifier.strip()
if "/" not in cleaned:
return None, cleaned
workspace_identifier, project_identifier = cleaned.split("/", 1)
if not workspace_identifier or not project_identifier:
return None, cleaned
return workspace_identifier, project_identifier
async def _resolve_project_identifier_candidate(
project_repository: ProjectRepository,
identifier: str,
) -> tuple[Project | None, ProjectResolveMethod]:
"""Resolve one project identifier candidate and report the matching method."""
identifier_permalink = generate_permalink(identifier)
project = await project_repository.get_by_external_id(identifier)
if project:
return project, "external_id"
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
return project, "permalink"
project = await project_repository.get_by_name_case_insensitive(identifier)
if project:
return project, "name" # pragma: no cover
return None, "name"
async def _resolve_project_identifier(
project_repository: ProjectRepository,
identifier: str,
) -> tuple[Project | None, ProjectResolveMethod]:
"""Resolve exact identifiers first, then accepted workspace-qualified forms."""
project, resolution_method = await _resolve_project_identifier_candidate(
project_repository,
identifier,
)
if project:
return project, resolution_method
workspace_identifier, project_identifier = _split_qualified_project_identifier(identifier)
if workspace_identifier is None:
return None, resolution_method
# Trigger: an MCP disambiguation error suggested ``workspace/project``.
# Why: request routing already selected the workspace/tenant; this endpoint
# only needs the project segment to validate the active project.
# Outcome: models can follow the hint verbatim instead of looping on a 404.
project, resolution_method = await _resolve_project_identifier_candidate(
project_repository,
project_identifier,
)
if project:
return project, resolution_method
return None, resolution_method
@router.get("/", response_model=ProjectList)
@@ -314,10 +247,28 @@ async def resolve_project_identifier(
"""
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
project, resolution_method = await _resolve_project_identifier(
project_repository,
data.identifier,
)
# Generate permalink for comparison
identifier_permalink = generate_permalink(data.identifier)
resolution_method = "name"
project = None
# Try external_id first (UUID format)
project = await project_repository.get_by_external_id(data.identifier)
if project:
resolution_method = "external_id"
# If not found by external_id, try by permalink (exact match)
if not project:
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
resolution_method = "permalink"
# If not found by permalink, try case-insensitive name search
if not project:
project = await project_repository.get_by_name_case_insensitive(data.identifier)
if project:
resolution_method = "name" # pragma: no cover
if not project:
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
+3 -5
View File
@@ -18,7 +18,7 @@ from basic_memory.services.context_service import (
class EntityBatchLookup(Protocol):
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Any]: ...
async def find_by_ids(self, ids: List[int]) -> Sequence[Any]: ...
class EntityServiceBatchLookup(Protocol):
@@ -76,7 +76,7 @@ async def to_graph_context(
if item.to_id:
entity_ids_needed.add(item.to_id)
# Batch fetch just the entity fields needed to shape the response.
# Batch fetch all entities at once - get both title and external_id
entity_title_lookup: dict[int, str] = {}
entity_external_id_lookup: dict[int, str] = {}
if entity_ids_needed:
@@ -87,9 +87,7 @@ async def to_graph_context(
phase="lookup_entities",
result_count=len(entity_ids_needed),
):
entities = await entity_repository.find_by_ids_for_hydration(
list(entity_ids_needed)
)
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
for e in entities:
entity_title_lookup[e.id] = e.title
entity_external_id_lookup[e.id] = e.external_id
+1 -2
View File
@@ -1,6 +1,6 @@
"""CLI commands for basic-memory."""
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
from . import (
import_claude_projects,
import_chatgpt,
@@ -18,7 +18,6 @@ __all__ = [
"import_memory_json",
"mcp",
"import_claude_conversations",
"orphans",
"import_claude_projects",
"import_chatgpt",
"tool",
@@ -14,24 +14,6 @@ class BisyncError(Exception):
pass
def _rclone_exclude_filters(pattern: str) -> list[str]:
"""Return rclone exclude filters for a gitignore-style pattern."""
if pattern.endswith("/"):
# Trigger: gitignore-style patterns ending in / are directory-only rules.
# Why: stripping the slash would also exclude a same-named file.
# Outcome: rclone keeps the directory rule and excludes recursive contents.
return [f"- {pattern}", f"- {pattern}**"]
path_pattern = pattern.removesuffix("/**")
# Trigger: rclone treats a directory contents filter separately from the
# directory/file path itself.
# Why: files like config.json and directory markers like .obsidian must both
# be excluded, along with anything below matching directories.
# Outcome: every ignore pattern excludes the direct match and recursive children.
return [f"- {path_pattern}", f"- {path_pattern}/**"]
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
@@ -96,11 +78,19 @@ def convert_bmignore_to_rclone_filters() -> Path:
patterns.append(line)
continue
patterns.extend(_rclone_exclude_filters(line))
# Convert gitignore pattern to rclone filter syntax
# gitignore: node_modules → rclone: - node_modules/**
# gitignore: *.pyc → rclone: - *.pyc
if "*" in line:
# Pattern already has wildcard, just add exclude prefix
patterns.append(f"- {line}")
else:
# Directory pattern - add /** for recursive exclude
patterns.append(f"- {line}/**")
except Exception:
# If we can't read the file, create a minimal filter
patterns = ["# Error reading .bmignore, using minimal filters", "- .git", "- .git/**"]
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
# Write rclone filter file
rclone_filter_path.write_text("\n".join(patterns) + "\n")
@@ -4,7 +4,7 @@ import os
import platform
import shutil
import subprocess
from typing import Any, Optional, cast
from typing import Optional
from rich.console import Console
@@ -53,9 +53,7 @@ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedP
def install_rclone_macos() -> None:
"""Install rclone on macOS using package managers."""
install_errors: list[str] = []
"""Install rclone on macOS using Homebrew or official script."""
# Try Homebrew first
if shutil.which("brew"):
try:
@@ -63,37 +61,35 @@ def install_rclone_macos() -> None:
run_command(["brew", "install", "rclone"])
console.print("[green]rclone installed via Homebrew[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"Homebrew failed: {exc}")
console.print("[yellow]Homebrew installation failed, trying MacPorts...[/yellow]")
except RcloneInstallError:
console.print(
"[yellow]Homebrew installation failed, trying official script...[/yellow]"
)
if shutil.which("port"):
try:
console.print("[blue]Installing rclone via MacPorts...[/blue]")
run_command(["sudo", "port", "install", "rclone"])
console.print("[green]rclone installed via MacPorts[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"MacPorts failed: {exc}")
console.print("[yellow]MacPorts installation failed[/yellow]")
details = "\n".join(f"- {error}" for error in install_errors)
if details:
details = f"\n\nAttempts:\n{details}"
raise RcloneInstallError(
"Could not install rclone automatically with an available package manager.\n\n"
"Install rclone manually with one of:\n"
" brew install rclone\n"
" sudo port install rclone\n"
" Download from https://rclone.org/downloads/ and add rclone to PATH"
f"{details}"
)
# Fallback to official script
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: brew install rclone"
)
def install_rclone_linux() -> None:
"""Install rclone on Linux using package managers."""
install_errors: list[str] = []
"""Install rclone on Linux using package managers or official script."""
# Try snap first (most universal)
if shutil.which("snap"):
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError:
console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
# Try apt (Debian/Ubuntu)
if shutil.which("apt"):
try:
console.print("[blue]Installing rclone via apt...[/blue]")
@@ -101,75 +97,18 @@ def install_rclone_linux() -> None:
run_command(["sudo", "apt", "install", "-y", "rclone"])
console.print("[green]rclone installed via apt[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"apt failed: {exc}")
console.print("[yellow]apt installation failed, trying dnf...[/yellow]")
except RcloneInstallError:
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
if shutil.which("dnf"):
try:
console.print("[blue]Installing rclone via dnf...[/blue]")
run_command(["sudo", "dnf", "install", "-y", "rclone"])
console.print("[green]rclone installed via dnf[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"dnf failed: {exc}")
console.print("[yellow]dnf installation failed, trying yum...[/yellow]")
if shutil.which("yum"):
try:
console.print("[blue]Installing rclone via yum...[/blue]")
run_command(["sudo", "yum", "install", "-y", "rclone"])
console.print("[green]rclone installed via yum[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"yum failed: {exc}")
console.print("[yellow]yum installation failed, trying pacman...[/yellow]")
if shutil.which("pacman"):
try:
console.print("[blue]Installing rclone via pacman...[/blue]")
run_command(["sudo", "pacman", "-S", "--noconfirm", "rclone"])
console.print("[green]rclone installed via pacman[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"pacman failed: {exc}")
console.print("[yellow]pacman installation failed, trying zypper...[/yellow]")
if shutil.which("zypper"):
try:
console.print("[blue]Installing rclone via zypper...[/blue]")
run_command(["sudo", "zypper", "--non-interactive", "install", "rclone"])
console.print("[green]rclone installed via zypper[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"zypper failed: {exc}")
console.print("[yellow]zypper installation failed, trying snap...[/yellow]")
if shutil.which("snap"):
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"snap failed: {exc}")
console.print("[yellow]snap installation failed[/yellow]")
details = "\n".join(f"- {error}" for error in install_errors)
if details:
details = f"\n\nAttempts:\n{details}"
raise RcloneInstallError(
"Could not install rclone automatically with an available package manager.\n\n"
"Install rclone manually with one of your OS package managers, for example:\n"
" sudo apt install rclone\n"
" sudo dnf install rclone\n"
" sudo yum install rclone\n"
" sudo pacman -S rclone\n"
" sudo zypper install rclone\n"
" sudo snap install rclone\n"
"Or download from https://rclone.org/downloads/ and add rclone to PATH"
f"{details}"
)
# Fallback to official script
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: sudo snap install rclone"
)
def install_rclone_windows() -> None:
@@ -270,38 +209,27 @@ def refresh_windows_path() -> None:
if platform.system().lower() != "windows":
return
# Importing here after performing platform detection. Non-Windows type checkers may still
# resolve a stub without registry members, so keep this platform-only module dynamic here.
# Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
# warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
import winreg
winreg_module = cast(Any, winreg)
user_key_path = r"Environment"
system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
new_path = ""
# Read user PATH
try:
reg_key = winreg_module.OpenKey(
winreg_module.HKEY_CURRENT_USER,
user_key_path,
0,
winreg_module.KEY_READ,
)
user_path, _ = winreg_module.QueryValueEx(reg_key, "PATH")
winreg_module.CloseKey(reg_key)
reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
user_path = ""
# Read system PATH
try:
reg_key = winreg_module.OpenKey(
winreg_module.HKEY_LOCAL_MACHINE,
system_key_path,
0,
winreg_module.KEY_READ,
)
system_path, _ = winreg_module.QueryValueEx(reg_key, "PATH")
winreg_module.CloseKey(reg_key)
reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
system_path = ""
@@ -6,11 +6,10 @@ from rich.table import Table
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import (
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_identifier,
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
console = Console()
@@ -63,10 +62,7 @@ def list_workspaces() -> None:
@workspace_app.command("set-default")
def set_default_workspace(
identifier: str = typer.Argument(
...,
help="Workspace name, slug, type, or tenant_id to set as default",
),
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
) -> None:
"""Set the default cloud workspace.
@@ -75,7 +71,6 @@ def set_default_workspace(
Examples:
bm cloud workspace set-default Personal
bm cloud workspace set-default organization
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
"""
@@ -92,19 +87,19 @@ def set_default_workspace(
console.print("[yellow]No accessible workspaces found.[/yellow]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, identifier)]
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
if not matches:
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(f"[red]Error: Workspace '{identifier}' matches multiple workspaces.[/red]")
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
selected = matches[0]
-93
View File
@@ -1,93 +0,0 @@
"""Orphans command - show entities with no relations in the knowledge graph."""
import json
from typing import Annotated, Optional
import typer
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
from rich.table import Table
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.clients.knowledge import KnowledgeClient
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas.v2.graph import GraphNode
console = Console()
async def run_orphans(project: Optional[str] = None) -> tuple[str, list[GraphNode]]:
"""Fetch entities that have no relations in the knowledge graph."""
project = project or ConfigManager().default_project
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
entities = await KnowledgeClient(client, project_item.external_id).get_orphans()
return project_item.name, entities
@app.command()
def orphans(
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
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 entities that have no relations in the knowledge graph.
Orphan entities have no incoming or outgoing connections. These may indicate
newly created notes not yet linked to other entities, or notes that have had
their relations removed.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
project_name, entities = run_with_cleanup(run_orphans(project))
if json_output:
print(json.dumps([entity.model_dump(mode="json") for entity in entities], indent=2))
return
if not entities:
console.print(f"[green]No orphan entities in project '{project_name}'[/green]")
return
table = Table(title=f"{project_name}: Entities Without Relations ({len(entities)} total)")
table.add_column("Title", style="cyan")
table.add_column("File Path", style="yellow")
table.add_column("Type", style="green")
for entity in entities:
table.add_row(
entity.title,
entity.file_path,
entity.note_type or "",
)
console.print(table)
except (ValueError, ToolError) as exc:
if json_output:
print(json.dumps({"error": str(exc)}, indent=2))
else:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(code=1)
except typer.Exit:
raise
except Exception as exc:
logger.error(f"Error fetching orphan entities: {exc}")
if json_output:
print(json.dumps({"error": str(exc)}, indent=2))
else:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(code=1) # pragma: no cover
+55 -237
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import cast
import typer
from loguru import logger
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
@@ -34,10 +33,6 @@ from basic_memory.schemas.cloud import (
CloudProjectIndexStatus,
CloudTenantIndexStatusResponse,
ProjectVisibility,
WorkspaceInfo,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_identifier,
)
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.utils import generate_permalink, normalize_project_path
@@ -286,25 +281,27 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
"""Resolve a workspace name, slug, type, or tenant_id to a tenant_id."""
"""Resolve a workspace name or tenant_id to a tenant_id."""
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
if workspace is not None:
workspaces = run_with_cleanup(get_available_workspaces())
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, workspace)]
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
if not matches:
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
if workspaces:
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(f"[red]Error: Workspace '{workspace}' matches multiple workspaces.[/red]")
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
return matches[0].tenant_id
@@ -315,9 +312,9 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
workspaces = run_with_cleanup(get_available_workspaces())
if len(workspaces) == 1:
return workspaces[0].tenant_id
except Exception as exc:
except Exception:
# Workspace resolution is optional until a command needs a specific tenant.
logger.debug("Workspace resolution failed: {}", exc)
pass
return None
@@ -326,11 +323,7 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
def list_projects(
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name, slug, type, or tenant_id",
),
workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
) -> None:
"""List Basic Memory projects from local and (when available) cloud."""
@@ -346,90 +339,17 @@ def list_projects(
try:
config = ConfigManager().config
workspace_filter = workspace
workspace_filter_requested = workspace_filter is not None
# Use explicit workspace, fall back to config default
effective_workspace = workspace or config.default_workspace
local_result: ProjectList | None = None
cloud_results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
available_cloud_workspaces: list[WorkspaceInfo] = []
cloud_result: ProjectList | None = None
cloud_error: Exception | None = None
cloud_workspace_error: Exception | None = None
failed_cloud_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
def _fetch_cloud_workspace_results() -> tuple[
list[tuple[WorkspaceInfo | None, ProjectList]],
list[WorkspaceInfo],
Exception | None,
list[tuple[WorkspaceInfo, Exception]],
]:
from basic_memory.mcp.project_context import (
get_available_workspaces,
)
try:
workspaces = run_with_cleanup(get_available_workspaces())
except Exception as exc:
fallback_workspace = workspace_filter or config.default_workspace
return (
[(None, run_with_cleanup(_list_projects(fallback_workspace)))],
[],
exc,
[],
)
selected_workspaces = workspaces
if workspace_filter is not None:
matches = [
ws for ws in workspaces if workspace_matches_identifier(ws, workspace_filter)
]
if not matches:
console.print(f"[red]Error: Workspace '{workspace_filter}' not found[/red]")
if workspaces:
console.print(
f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]"
)
raise typer.Exit(1)
if len(matches) > 1:
console.print(
f"[red]Error: Workspace '{workspace_filter}' matches multiple workspaces.[/red]"
)
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
)
raise typer.Exit(1)
selected_workspaces = matches
if not selected_workspaces:
return [], workspaces, None, []
results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
failed_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
for cloud_workspace in selected_workspaces:
try:
results.append(
(
cloud_workspace,
run_with_cleanup(_list_projects(cloud_workspace.tenant_id)),
)
)
except Exception as exc:
failed_workspaces.append((cloud_workspace, exc))
if not results and failed_workspaces:
raise failed_workspaces[0][1]
return results, workspaces, None, failed_workspaces
if cloud:
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
with force_routing(cloud=True):
(
cloud_results,
available_cloud_workspaces,
cloud_workspace_error,
failed_cloud_workspaces,
) = _fetch_cloud_workspace_results()
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
elif local:
with force_routing(local=True):
local_result = run_with_cleanup(_list_projects())
@@ -442,17 +362,29 @@ def list_projects(
try:
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
with force_routing(cloud=True):
(
cloud_results,
available_cloud_workspaces,
cloud_workspace_error,
failed_cloud_workspaces,
) = _fetch_cloud_workspace_results()
except typer.Exit:
raise
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
except Exception as exc: # pragma: no cover
cloud_error = exc
# Resolve workspace name for cloud projects (best-effort)
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
if cloud_result and effective_workspace:
try:
from basic_memory.mcp.project_context import get_available_workspaces
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
workspaces = run_with_cleanup(get_available_workspaces())
matched = next(
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
None,
)
if matched:
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
except Exception:
pass
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
@@ -463,126 +395,29 @@ def list_projects(
table.add_column("Sync", style="green")
table.add_column("Default", style="magenta")
row_names_by_key: dict[tuple[str | None, str], str] = {}
project_names_by_permalink: dict[str, str] = {}
local_projects_by_permalink: dict[str, ProjectItem] = {}
cloud_projects_by_key: dict[tuple[str | None, str], ProjectItem] = {}
cloud_workspaces_by_key: dict[tuple[str | None, str], WorkspaceInfo | None] = {}
cloud_projects_by_permalink: dict[str, ProjectItem] = {}
if local_result:
for project in local_result.projects:
permalink = generate_permalink(project.name)
project_names_by_permalink[permalink] = project.name
local_projects_by_permalink[permalink] = project
for cloud_workspace, cloud_result in cloud_results:
workspace_key = cloud_workspace.tenant_id if cloud_workspace else None
if cloud_result:
for project in cloud_result.projects:
permalink = generate_permalink(project.name)
row_key = (workspace_key, permalink)
row_names_by_key[row_key] = project.name
cloud_projects_by_key[row_key] = project
cloud_workspaces_by_key[row_key] = cloud_workspace
cloud_permalinks = {permalink for _, permalink in cloud_projects_by_key}
for permalink, project in local_projects_by_permalink.items():
if permalink not in cloud_permalinks:
row_names_by_key[(None, permalink)] = project.name
cloud_keys_by_permalink: dict[str, list[tuple[str | None, str]]] = {}
for row_key in cloud_projects_by_key:
cloud_keys_by_permalink.setdefault(row_key[1], []).append(row_key)
configured_names_by_permalink = {
generate_permalink(project_name): project_name for project_name in config.projects
}
def _workspace_priority(row_key: tuple[str | None, str]) -> tuple[bool, int, str, str]:
"""Prefer the user's default/personal workspace when a project is duplicated."""
workspace = cloud_workspaces_by_key.get(row_key)
if workspace is None:
return (True, 2, "", row_key[0] or "")
workspace_type_rank = 0 if workspace.workspace_type == "personal" else 1
return (
not workspace.is_default,
workspace_type_rank,
workspace.name.casefold(),
row_key[0] or "",
)
def _select_attached_row_key(
permalink: str, entry: ProjectEntry | None
) -> tuple[str | None, str] | None:
"""Choose the single row that owns local config/default/sync state."""
cloud_keys = cloud_keys_by_permalink.get(permalink, [])
if not cloud_keys:
return (None, permalink)
preferred_workspace_ids: list[str] = []
if entry and entry.workspace_id:
preferred_workspace_ids.append(entry.workspace_id)
if config.default_workspace and config.default_workspace not in preferred_workspace_ids:
preferred_workspace_ids.append(config.default_workspace)
default_cloud_workspace = next(
(item for item in available_cloud_workspaces if item.is_default),
None,
)
if (
default_cloud_workspace
and default_cloud_workspace.tenant_id not in preferred_workspace_ids
):
preferred_workspace_ids.append(default_cloud_workspace.tenant_id)
for workspace_id in preferred_workspace_ids:
for row_key in cloud_keys:
if row_key[0] == workspace_id:
return row_key
if workspace_filter_requested and preferred_workspace_ids:
# A filtered list can exclude the workspace that owns local config state.
# In that case, do not attach local/default/sync state to another workspace row.
return None
default_workspace_keys = [
row_key
for row_key in cloud_keys
if (row_workspace := cloud_workspaces_by_key.get(row_key)) is not None
and row_workspace.is_default
]
if len(default_workspace_keys) == 1:
return default_workspace_keys[0]
if len(cloud_keys) == 1:
return cloud_keys[0]
return sorted(cloud_keys, key=_workspace_priority)[0]
attached_row_by_permalink: dict[str, tuple[str | None, str] | None] = {}
for permalink in set(local_projects_by_permalink) | set(configured_names_by_permalink):
configured_name = configured_names_by_permalink.get(permalink)
local_project = local_projects_by_permalink.get(permalink)
entry_name = configured_name or (local_project.name if local_project else None)
entry = config.projects.get(entry_name) if entry_name else None
attached_row_by_permalink[permalink] = _select_attached_row_key(permalink, entry)
project_names_by_permalink[permalink] = project.name
cloud_projects_by_permalink[permalink] = project
# --- Build unified project list ---
project_rows: list[dict] = []
sorted_row_keys = sorted(
row_names_by_key,
key=lambda key: (row_names_by_key[key], key[0] or ""),
)
for row_key in sorted_row_keys:
_, permalink = row_key
project_name = row_names_by_key[row_key]
is_attached_row = attached_row_by_permalink.get(permalink) == row_key
local_project = local_projects_by_permalink.get(permalink) if is_attached_row else None
cloud_project = cloud_projects_by_key.get(row_key)
cloud_workspace = cloud_workspaces_by_key.get(row_key)
configured_name = configured_names_by_permalink.get(permalink)
configured_entry = (
config.projects.get(configured_name)
if configured_name
else config.projects.get(project_name)
)
entry = configured_entry if is_attached_row else None
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)
local_path = ""
if local_project is not None:
@@ -612,13 +447,9 @@ def list_projects(
else:
cli_route = ProjectMode.LOCAL.value
default_permalink = (
generate_permalink(config.default_project) if config.default_project else None
)
is_default = bool(is_attached_row and permalink == default_permalink)
is_default = config.default_project == project_name
sync_supported = cloud_workspace is None or cloud_workspace.workspace_type == "personal"
has_sync = bool(is_attached_row and entry and entry.local_sync_path and sync_supported)
has_sync = bool(entry and entry.local_sync_path)
# Determine MCP transport based on project routing mode
if entry and entry.mode == ProjectMode.CLOUD:
mcp_transport = "https"
@@ -628,8 +459,9 @@ def list_projects(
mcp_transport = "stdio"
# Show workspace name (type) for cloud-sourced projects
cloud_ws_name = cloud_workspace.name if cloud_workspace else None
cloud_ws_type = cloud_workspace.workspace_type if cloud_workspace else None
ws_label = ""
if cloud_project is not None and cloud_ws_name:
ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name
# display_name is a human label for private UUID-named projects (e.g., "My Project").
# Keep "name" as the canonical identifier for scripting/JSON consumers;
@@ -649,8 +481,8 @@ def list_projects(
}
if display_name:
row_data["display_name"] = display_name
if cloud_project is not None and cloud_ws_name:
row_data["workspace"] = cloud_ws_name
if ws_label:
row_data["workspace"] = cloud_ws_name or ""
if cloud_ws_type:
row_data["workspace_type"] = cloud_ws_type
@@ -682,20 +514,6 @@ def list_projects(
"[dim]Showing local projects only. "
"Run 'bm cloud login' or 'bm cloud api-key save <key>' if this is a credentials issue.[/dim]"
)
if cloud_workspace_error is not None:
console.print(
f"[yellow]Cloud workspace discovery failed: {cloud_workspace_error}[/yellow]"
)
console.print(
"[dim]Showing cloud projects from the configured/default workspace only.[/dim]"
)
for failed_workspace, error in failed_cloud_workspaces:
console.print(
f"[yellow]Cloud project discovery failed for workspace "
f"{failed_workspace.name}: {error}[/yellow]"
)
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error listing projects: {str(e)}[/red]")
raise typer.Exit(1)
@@ -713,7 +531,7 @@ def add_project(
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name, slug, type, or tenant_id (cloud mode only)",
help="Cloud workspace name or tenant_id (cloud mode only)",
),
visibility: str = typer.Option(
None,
@@ -1096,7 +914,7 @@ def set_cloud(
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name, slug, type, or tenant_id to associate with this project",
help="Cloud workspace name or tenant_id to associate with this project",
),
) -> None:
"""Set a project to cloud mode (route through cloud API).
-1
View File
@@ -24,7 +24,6 @@ if not _version_only_invocation(sys.argv[1:]):
import_claude_projects,
import_memory_json,
mcp,
orphans,
project,
schema,
status,
+12 -41
View File
@@ -21,27 +21,13 @@ from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = "basic-memory"
DATA_DIR_NAME = ".basic-memory"
CONFIG_FILE_NAME = "config.json"
WATCH_STATUS_JSON = "watch-status.json"
CONFIG_DIR_MODE = 0o700
CONFIG_FILE_MODE = 0o600
Environment = Literal["test", "dev", "user"]
def _secure_config_dir(path: Path) -> None:
"""Restrict config directory permissions on platforms with POSIX modes."""
if os.name != "nt":
path.chmod(CONFIG_DIR_MODE)
def _secure_config_file(path: Path) -> None:
"""Restrict config file permissions because config can contain cloud credentials."""
if os.name != "nt":
path.chmod(CONFIG_FILE_MODE)
class ProjectMode(str, Enum):
"""Per-project routing mode."""
@@ -69,18 +55,15 @@ def resolve_data_dir() -> Path:
Single source of truth for the per-user state directory. Honors
``BASIC_MEMORY_CONFIG_DIR`` so each process/worktree can isolate config
and database state; otherwise falls back to ``<user home>/.basic-memory``,
and then to ``XDG_CONFIG_HOME``.
and database state; otherwise falls back to ``<user home>/.basic-memory``.
Cross-platform: ``Path.home()`` reads ``$HOME`` on POSIX and
``%USERPROFILE%`` on Windows, so there's no need to check ``$HOME``
explicitly here.
"""
if basic_memory_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
return Path(basic_memory_dir)
if xdg_config := os.getenv("XDG_CONFIG_HOME"):
return Path(xdg_config) / DATA_DIR_NAME
return Path.home() / ("." + DATA_DIR_NAME)
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
return Path(config_dir)
return Path.home() / DATA_DIR_NAME
def default_fastembed_cache_dir() -> str:
@@ -185,15 +168,13 @@ class BasicMemoryConfig(BaseSettings):
env: Environment = Field(default="dev", description="Environment name")
projects: Dict[str, ProjectEntry] = Field(
default_factory=lambda: (
{
"main": ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
}
if os.getenv("BASIC_MEMORY_HOME")
else {}
),
default_factory=lambda: {
"main": ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
}
if os.getenv("BASIC_MEMORY_HOME")
else {},
description="Mapping of project names to their ProjectEntry configuration",
)
default_project: Optional[str] = Field(
@@ -297,11 +278,6 @@ class BasicMemoryConfig(BaseSettings):
description="Optional FastEmbed embed() parallelism override.",
gt=0,
)
import_upload_max_bytes: int = Field(
default=100 * 1024 * 1024,
description="Maximum uploaded JSON export size accepted by API import endpoints.",
gt=0,
)
semantic_vector_k: int = Field(
default=100,
description="Vector candidate count for vector and hybrid retrieval.",
@@ -800,7 +776,6 @@ class ConfigManager:
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
_secure_config_dir(self.config_dir)
@property
def config(self) -> BasicMemoryConfig:
@@ -913,7 +888,6 @@ class ConfigManager:
# Create backup before overwriting so users can revert if needed
backup_path = self.config_file.with_suffix(".json.bak")
shutil.copy2(self.config_file, backup_path)
_secure_config_file(backup_path)
logger.info(f"Migrating config to current format (backup: {backup_path})")
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
@@ -1069,12 +1043,9 @@ def has_cloud_credentials(config: BasicMemoryConfig) -> bool:
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
_secure_config_dir(file_path.parent)
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
file_path.write_text(json.dumps(config_dict, indent=2))
_secure_config_file(file_path)
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+8 -30
View File
@@ -85,28 +85,6 @@ def parse_observation(token: Token) -> Dict[str, Any]:
# Relation handling functions
def parse_relation_type(content: str) -> str | None:
"""Return the explicit relation label before the first wikilink, if any."""
before_link = content.partition("[[")[0].strip()
if not before_link:
return None
# Trigger: relation labels that need spaces must be quoted.
# Why: unquoted multi-word prefixes are indistinguishable from prose
# containing a wikilink.
# Outcome: `some_type [[Target]]`, `"some type" [[Target]]`, and
# `'some type' [[Target]]` are explicit; `some other thing [[Target]]`
# falls back to inline `links_to` handling.
quote = before_link[0]
if quote in {"'", '"'} and before_link.endswith(quote):
quoted_label = before_link[1:-1].strip()
return quoted_label or None
if any(char.isspace() for char in before_link):
return None
return before_link
def is_explicit_relation(token: Token) -> bool:
"""Check if token looks like our relation format."""
if token.type != "inline": # pragma: no cover
@@ -114,7 +92,7 @@ def is_explicit_relation(token: Token) -> bool:
# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
return "[[" in content and "]]" in content and parse_relation_type(content) is not None
return "[[" in content and "]]" in content
def parse_relation(token: Token) -> Dict[str, Any] | None:
@@ -123,18 +101,20 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
rel_type = parse_relation_type(content)
if rel_type is None:
return None
# Extract [[target]]
target = None
rel_type = "relates_to" # default
context = None
start = content.find("[[")
end = content.find("]]", start + 2)
end = content.find("]]")
if start != -1 and end != -1:
# Get text before link as relation type
before = content[:start].strip()
if before:
rel_type = before
# Get target
target = normalize_project_reference(content[start + 2 : end].strip())
@@ -237,8 +217,6 @@ def relation_plugin(md: MarkdownIt) -> None:
Explicit relations:
- relation_type [[target]] (context)
- "multi word relation type" [[target]] (context)
- 'multi word relation type' [[target]] (context)
Implicit relations (links in content):
Some text with [[target]] reference
+2 -11
View File
@@ -6,6 +6,7 @@ from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
import logfire
from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager, ProjectMode
@@ -36,18 +37,8 @@ def _build_timeout() -> Timeout:
def _asgi_client(timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client."""
# Import on first local-client use so CLI help/version paths can import
# routing helpers without constructing the full FastAPI router graph.
from basic_memory.api.app import app as fastapi_app
from basic_memory.workspace_context import workspace_permalink_headers
return AsyncClient(
transport=ASGITransport(app=fastapi_app),
base_url="http://test",
timeout=timeout,
# Local ASGI calls still cross the HTTP boundary, so request handlers need
# the same workspace permalink metadata that cloud proxy calls receive.
headers=workspace_permalink_headers(),
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
-19
View File
@@ -15,7 +15,6 @@ from basic_memory.schemas.response import (
DirectoryMoveResult,
DirectoryDeleteResult,
)
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
class KnowledgeClient:
@@ -276,24 +275,6 @@ class KnowledgeClient:
)
return DirectoryDeleteResult.model_validate(response.json())
# --- Orphan detection ---
async def get_orphans(self) -> list[GraphNode]:
"""Get entities that have no incoming or outgoing relations."""
with logfire.span(
"mcp.client.knowledge.get_orphans",
client_name="knowledge",
operation="get_orphans",
):
response = await call_get(
self.http_client,
f"{self._base_path}/orphans",
client_name="knowledge",
operation="get_orphans",
path_template="/v2/projects/{project_id}/knowledge/orphans",
)
return OrphanEntitiesResponse.model_validate(response.json()).entities
# --- Resolution ---
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
+82 -290
View File
@@ -11,7 +11,7 @@ compatibility with existing MCP tools.
import asyncio
from contextlib import asynccontextmanager, nullcontext
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
from uuid import UUID
from httpx import AsyncClient
@@ -25,22 +25,11 @@ from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
from basic_memory.project_resolver import ProjectResolver
from basic_memory.schemas.cloud import (
WorkspaceInfo,
WorkspaceListResponse,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_exact_identifier,
workspace_matches_identifier,
)
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 (
build_qualified_permalink_reference,
generate_permalink,
normalize_project_reference,
)
from basic_memory.utils import generate_permalink, normalize_project_reference
from basic_memory.workspace_context import (
current_workspace_permalink_context,
workspace_permalink_context,
@@ -142,15 +131,6 @@ async def _set_cached_active_project(
await context.set_state("default_project_name", active_project.name)
async def _clear_cached_active_project(context: Optional[Context]) -> None:
"""Clear cached project metadata that may no longer match the active route."""
if not context:
return
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
"""Return the cached active workspace from context when available."""
if not context:
@@ -176,22 +156,12 @@ async def _set_cached_active_workspace(
# Why: project names are only unique inside one workspace, so a cached
# ProjectItem from the previous tenant can point at the wrong project
# Outcome: force the next validation call to resolve within the new tenant
await _clear_cached_active_project(context)
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
await context.set_state("active_workspace", active_workspace.model_dump())
async def _clear_cached_active_workspace_for_local_route(context: Optional[Context]) -> None:
"""Drop tenant workspace metadata before routing through a local project."""
if not context:
return
# Trigger: local routing follows a cloud route in the same MCP session
# Why: active_workspace is tenant metadata, not part of local project identity
# Outcome: memory:// resolution uses project-only local permalinks
await context.set_state("active_workspace", None)
async def _get_cached_default_project(context: Optional[Context]) -> Optional[str]:
"""Return the cached default project name from context when available."""
if not context:
@@ -314,6 +284,29 @@ 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, slug, or name."""
if workspace.tenant_id == identifier:
return True
if workspace.slug.casefold() == identifier.casefold():
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"(slug={item.slug}, type={item.workspace_type}, "
f"role={item.role}, tenant_id={item.tenant_id})"
)
for item in workspaces
]
)
def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex | None:
"""Deserialize a cached workspace project index from MCP context state."""
if not isinstance(raw, dict):
@@ -413,20 +406,14 @@ def _unqualified_project_identifier(identifier: str) -> str:
return project_identifier
def _identifier_path(identifier: str) -> str:
"""Return the routable path portion of a raw identifier or memory URL."""
stripped = identifier.strip()
return memory_url_path(stripped) if stripped.startswith("memory://") else stripped
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
if not identifier.strip().startswith("memory://"):
return None
def _split_workspace_identifier_segments(identifier: str) -> tuple[str, str, str] | None:
"""Split ``<workspace>/<project>/<path>`` identifiers into route segments."""
normalized = normalize_project_reference(_identifier_path(identifier)).strip("/")
normalized = normalize_project_reference(memory_url_path(identifier))
parts = normalized.split("/", 2)
if len(parts) != 3:
# Trigger: two-segment identifiers such as `workspace/project` or `project/path`.
# Why: without a remainder, the shape is ambiguous with existing project-prefix routing.
# Outcome: fall through so the normal project-prefix/default-project resolver decides.
return None
workspace_slug, project_identifier, remainder = parts
@@ -435,81 +422,28 @@ def _split_workspace_identifier_segments(identifier: str) -> tuple[str, str, str
return workspace_slug, project_identifier, remainder
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
if not identifier.strip().startswith("memory://"):
return None
return _split_workspace_identifier_segments(identifier)
def _canonical_memory_path_for_workspace(
*,
workspace_slug: str,
workspace_type: str,
project_permalink: str,
remainder: str,
include_project: bool,
) -> str:
"""Return the stored canonical path for a workspace-qualified memory URL."""
normalized_remainder = remainder.strip("/")
if workspace_type not in {"organization", "personal"}:
if workspace_type == "organization":
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
elif workspace_type == "personal":
prefix = project_permalink if include_project else ""
else:
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
# Trigger: a caller supplied a workspace-qualified memory URL.
# Why: the first two path segments are the global route, even for Personal.
# Outcome: lookups preserve the complete workspace/project canonical permalink.
if not prefix:
return normalized_remainder
if not normalized_remainder:
normalized_remainder = project_permalink
return build_qualified_permalink_reference(
project_permalink,
normalized_remainder,
include_project=True,
workspace_permalink=workspace_slug,
)
def _canonical_memory_path_for_active_route(
active_project: ProjectItem,
path: str,
*,
include_project: bool,
cached_workspace: WorkspaceInfo | None = None,
) -> str:
"""Return the canonical permalink path for the currently routed project/workspace."""
project_prefix = active_project.permalink
workspace_remainder = path
if include_project and (path == project_prefix or path.startswith(f"{project_prefix}/")):
# Trigger: the memory URL already names the active project root/prefix
# Why: workspace canonicalization adds the project prefix itself, so
# keeping it in the remainder would produce <workspace>/<project>/<project>
# Outcome: keep project-root and project-prefixed URLs canonical once
workspace_remainder = (
"" if path == project_prefix else path.removeprefix(f"{project_prefix}/")
)
workspace_context = current_workspace_permalink_context()
if workspace_context is not None:
return _canonical_memory_path_for_workspace(
workspace_slug=workspace_context.workspace_slug,
workspace_type=workspace_context.workspace_type,
project_permalink=active_project.permalink,
remainder=workspace_remainder,
)
if cached_workspace is not None:
return _canonical_memory_path_for_workspace(
workspace_slug=cached_workspace.slug,
workspace_type=cached_workspace.workspace_type,
project_permalink=active_project.permalink,
remainder=workspace_remainder,
)
if not include_project:
return path
if path == project_prefix or path.startswith(f"{project_prefix}/"):
return path
return f"{project_prefix}/{path}"
return prefix
return f"{prefix}/{normalized_remainder}"
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
@@ -533,28 +467,6 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
)
def _workspace_identifier_discovery_available(
identifier: str,
config: BasicMemoryConfig,
) -> bool:
"""Return True when an identifier is allowed to consult workspace discovery."""
if _cloud_workspace_discovery_available(config):
return True
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
)
if _explicit_routing() and _force_local_mode():
return False
return (
has_cloud_credentials(config)
and _split_workspace_identifier_segments(identifier) is not None
)
async def resolve_workspace_qualified_memory_url(
identifier: str,
context: Optional[Context] = None,
@@ -564,27 +476,6 @@ async def resolve_workspace_qualified_memory_url(
if segments is None:
return None
return await _resolve_workspace_segments(identifier, segments, context=context)
async def resolve_workspace_qualified_identifier(
identifier: str,
context: Optional[Context] = None,
) -> WorkspaceMemoryUrlResolution | None:
"""Resolve a workspace-qualified permalink or memory URL against accessible workspaces."""
segments = _split_workspace_identifier_segments(identifier)
if segments is None:
return None
return await _resolve_workspace_segments(identifier, segments, context=context)
async def _resolve_workspace_segments(
identifier: str,
segments: tuple[str, str, str],
context: Optional[Context] = None,
) -> WorkspaceMemoryUrlResolution | None:
"""Resolve parsed workspace/project/path segments against accessible workspaces."""
workspace_slug, project_identifier, remainder = segments
index = await _ensure_workspace_project_index(context=context)
workspace = next(
@@ -634,11 +525,12 @@ async def _resolve_workspace_segments(
workspace_type=entry.workspace.workspace_type,
project_permalink=entry.project.permalink,
remainder=remainder,
include_project=ConfigManager().config.permalinks_include_project,
)
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
def _format_qualified_choices(entries: Sequence[WorkspaceProjectEntry]) -> str:
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
"""Format qualified project choices for collision errors."""
return " or ".join(entry.qualified_name for entry in entries)
@@ -764,7 +656,7 @@ async def _ensure_workspace_project_index(
)
continue
workspace_entries = result
workspace_entries = cast(tuple[WorkspaceProjectEntry, ...], result)
successful_fetches += 1
entries_list.extend(workspace_entries)
@@ -867,7 +759,7 @@ async def resolve_workspace_project_identifier(
)
return matches[0]
matches = list(index.entries_by_permalink.get(project_permalink, ()))
matches = index.entries_by_permalink.get(project_permalink, ())
if not matches:
failed_note = ""
if index.failed_workspaces:
@@ -1001,9 +893,7 @@ async def resolve_workspace_parameter(
cached_raw = await context.get_state("active_workspace")
if isinstance(cached_raw, dict):
cached_workspace = WorkspaceInfo.model_validate(cached_raw)
if workspace is None or workspace_matches_exact_identifier(
cached_workspace, workspace
):
if workspace is None or _workspace_matches_identifier(cached_workspace, workspace):
logger.debug(
f"Using cached workspace from context: {cached_workspace.tenant_id}"
)
@@ -1019,17 +909,19 @@ async def resolve_workspace_parameter(
selected_workspace: WorkspaceInfo | None = None
if workspace:
matches = [item for item in workspaces if workspace_matches_identifier(item, 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{format_workspace_choices(workspaces)}"
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
if len(matches) > 1:
raise ValueError(
f"Workspace '{workspace}' matches multiple workspaces. "
"Choose one of these matching workspaces by slug or tenant_id:\n"
f"{format_workspace_selection_choices(matches)}"
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:
@@ -1037,8 +929,8 @@ async def resolve_workspace_parameter(
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/slug/type.\n"
f"Available workspaces:\n{format_workspace_choices(workspaces)}"
"with the 'workspace' argument set to the tenant_id or unique name.\n"
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
await _set_cached_active_workspace(context, selected_workspace)
@@ -1181,6 +1073,7 @@ async def resolve_project_and_path(
workspace_type=cached_workspace.workspace_type,
project_permalink=cached_project.permalink,
remainder=remainder,
include_project=bool(include_project),
)
return cached_project, resolved_path, True
@@ -1203,6 +1096,7 @@ async def resolve_project_and_path(
workspace_type=workspace_context.workspace_type,
project_permalink=project_permalink,
remainder=remainder,
include_project=bool(include_project),
)
return active_project, resolved_path, True
@@ -1221,11 +1115,8 @@ async def resolve_project_and_path(
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
)
resolved_path = _canonical_memory_path_for_active_route(
cached_project,
remainder,
include_project=include_project,
cached_workspace=cached_workspace,
resolved_path = (
f"{cached_project.permalink}/{remainder}" if include_project else remainder
)
return cached_project, resolved_path, True
@@ -1260,24 +1151,25 @@ async def resolve_project_and_path(
)
await _set_cached_active_project(context, active_project)
resolved_path = _canonical_memory_path_for_active_route(
active_project,
remainder,
include_project=include_project,
cached_workspace=cached_workspace,
resolved_path = (
f"{resolved.permalink}/{remainder}" if include_project else remainder
)
return active_project, resolved_path, True
# Trigger: memory URL has no resolvable project route segment
# Why: preserve active-project behavior while honoring workspace paths
# Outcome: normalize against the already-selected local/cloud route
# 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 = _canonical_memory_path_for_active_route(
active_project,
normalized_path,
include_project=include_project,
cached_workspace=cached_workspace,
)
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
@@ -1335,63 +1227,17 @@ async def detect_project_from_memory_url_prefix(
if not identifier.strip().startswith("memory://"):
return None
return await detect_project_from_identifier_prefix(identifier, config, context=context)
async def detect_project_from_identifier_prefix(
identifier: str,
config: BasicMemoryConfig,
context: Optional[Context] = None,
) -> Optional[str]:
"""Resolve a project from a plain permalink, memory URL, or workspace route prefix."""
local_project = detect_project_from_url_prefix(identifier, config)
if local_project is not None:
return local_project
normalized_identifier = normalize_project_reference(_identifier_path(identifier)).strip("/")
if "/" not in normalized_identifier:
# Trigger: plain text search query or single-segment title/permalink.
# Why: cloud project discovery can build a workspace index; only path-shaped
# identifiers carry enough structure to justify that cost.
# Outcome: keep unqualified search/title input on the active/default project route.
return None
if _workspace_identifier_discovery_available(identifier, config):
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
if _cloud_workspace_discovery_available(config):
resolution = await resolve_workspace_qualified_memory_url(
identifier,
context=context,
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
if workspace_resolution is not None:
return workspace_resolution.project_identifier
project_prefix, _ = _split_project_prefix(normalized_identifier)
if project_prefix is None:
return None
try:
project_resolution = await resolve_workspace_project_identifier(
project_prefix,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
return project_resolution.qualified_name
if resolution is not None:
return resolution.project_identifier
return None
@@ -1479,7 +1325,6 @@ async def get_project_client(
# Outcome: route strictly based on explicit flag, no workspace network calls
if _explicit_routing() and _force_local_mode():
route_mode = "explicit_local"
await _clear_cached_active_workspace_for_local_route(context)
with logfire.span(
"routing.client_session",
project_name=resolved_project,
@@ -1504,54 +1349,9 @@ async def get_project_client(
if project_id and not cloud_available:
project_mode = ProjectMode.LOCAL
# Trigger: project_id is a local external_id in a mixed local+cloud setup.
# Why: UUIDs are not local config keys, so get_project_mode() treats them as
# cloud projects. A local-first probe avoids making local UUIDs depend on
# healthy cloud workspace discovery.
# Outcome: resolve the effective UUID against local ASGI first; if it is not
# local, preserve the existing cloud workspace lookup path.
if (
project_id
and config.projects
and not factory_mode
and not explicit_cloud_routing
and project_mode == ProjectMode.CLOUD
):
try:
canonical_project_id = str(UUID(resolved_project))
except ValueError:
pass
else:
with logfire.span(
"routing.local_project_id_probe",
project_id=canonical_project_id,
):
async with get_client() as client:
try:
active_project = await get_active_project(
client,
canonical_project_id,
context,
)
except ToolError as exc:
if "not found" not in str(exc).lower():
raise
else:
route_mode = "local_asgi"
await _clear_cached_active_workspace_for_local_route(context)
with logfire.span(
"routing.client_session",
project_name=active_project.name,
route_mode=route_mode,
):
logger.debug("Using local ASGI routing for project_id")
yield client, active_project
return
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
route_mode = "factory" if factory_mode else "cloud_proxy"
active_ws: WorkspaceInfo | None = None
resolved_entry: WorkspaceProjectEntry | None = None
workspace_id: str
project_for_api = _unqualified_project_identifier(resolved_project)
@@ -1574,13 +1374,6 @@ async def get_project_client(
if active_ws is not None:
await _set_cached_active_workspace(context, active_ws)
if resolved_entry is not None:
cached_project = await _get_cached_active_project(context)
if (
cached_project is not None
and cached_project.external_id != resolved_entry.project.external_id
):
await _clear_cached_active_project(context)
with logfire.span(
"routing.client_session",
project_name=project_for_api,
@@ -1604,7 +1397,6 @@ async def get_project_client(
# Step 4: Local routing (default)
route_mode = "local_asgi"
await _clear_cached_active_workspace_for_local_route(context)
with logfire.span(
"routing.client_session",
project_name=resolved_project,
+18 -29
View File
@@ -6,7 +6,7 @@ a list containing a single `{"type": "text", "text": "{...json...}"}` item.
"""
import json
from typing import Any, Dict, List, Optional, cast
from typing import Any, Dict, List, Optional
from fastmcp import Context
from loguru import logger
@@ -17,30 +17,18 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.search import SearchResponse, SearchResult
def _identifier_for_read_note(identifier: str) -> str:
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://") or "/" not in stripped:
return identifier
return f"memory://{stripped}"
def _format_search_results_for_chatgpt(
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
) -> List[Dict[str, Any]]:
"""Format search results according to ChatGPT's expected schema.
Returns a list of result objects with id, title, and url fields.
"""
if isinstance(results, SearchResponse):
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
elif isinstance(results, dict):
nested_results = results.get("results")
raw_results = (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
raw_results = nested_results if isinstance(nested_results, list) else []
else:
raw_results = results
@@ -125,7 +113,8 @@ async def search(
logger.info(f"ChatGPT search request: query='{query}'")
try:
# Keep this adapter tiny: the real search behavior lives in search_notes.
# Let search_notes resolve the default project via get_project_client(),
# which works in both local mode (ConfigManager) and cloud mode (database).
results = await search_notes(
query=query,
page=1,
@@ -134,6 +123,7 @@ async def search(
context=context,
)
# Handle string error responses from search_notes
if isinstance(results, str):
logger.warning(f"Search failed with error: {results[:100]}...")
search_results = {
@@ -141,17 +131,16 @@ async def search(
"error": "Search failed",
"error_details": results[:500], # Truncate long error messages
}
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
raw_results = results.get("results", []) if isinstance(results, dict) else []
formatted_results = _format_search_results_for_chatgpt(raw_results)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
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)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
# Return in MCP content array format as required by OpenAI
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
@@ -191,7 +180,7 @@ async def fetch(
# which works in both local mode (ConfigManager) and cloud mode (database).
content = str(
await read_note(
identifier=_identifier_for_read_note(id),
identifier=id,
context=context,
)
)
+11 -107
View File
@@ -9,7 +9,6 @@ from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
@@ -18,11 +17,7 @@ from basic_memory.mcp.project_context import (
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import Entity
from basic_memory.schemas.response import EntityResponse
from basic_memory.services.link_resolver import (
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
)
from basic_memory.utils import normalize_project_reference, validate_project_path
from basic_memory.utils import validate_project_path
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
@@ -52,58 +47,6 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]
return title, directory
def _compose_workspace_project_route(
*,
workspace: Optional[str],
project: Optional[str],
project_id: Optional[str],
) -> Optional[str]:
"""Return the explicit project route requested by workspace/project args."""
if workspace is None:
return project
cleaned_workspace = workspace.strip().strip("/")
if not cleaned_workspace:
raise ValueError("workspace must not be empty when provided")
if "/" in cleaned_workspace:
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
if project_id is not None:
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
if project is None or not project.strip().strip("/"):
raise ValueError("workspace requires an explicit project argument")
cleaned_project = project.strip().strip("/")
if "/" in cleaned_project:
raise ValueError(
"Use either workspace='workspace' with project='project', "
"or project='workspace/project', not both"
)
return f"{cleaned_workspace}/{cleaned_project}"
def _format_ambiguous_workspace_identifier_response(
*,
identifier: str,
detected_project: str,
) -> str:
"""Format the safe-stop response for ambiguous plain write identifiers."""
cleaned_identifier = identifier.strip()
normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/")
workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2)
return f"""# Edit Failed - Ambiguous Identifier
`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`.
Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path.
Retry with one of these explicit routes:
- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)`
- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""
def _format_error_response(
error_message: str,
operation: str,
@@ -238,7 +181,6 @@ async def edit_note(
),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
project_id: Optional[str] = None,
# Section/heading naming varies across tools; accept the descriptive forms.
section: Annotated[
@@ -282,10 +224,7 @@ async def edit_note(
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
content: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
Use "workspace/project" to route to a project in a specific cloud workspace.
If unknown, use list_memory_projects() to discover available projects.
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
routes as `workspace/project`. Cannot be combined with `project_id`.
project_id: Project external_id (UUID). Prefer this over `project` when known
it routes to the exact project regardless of name collisions across cloud
workspaces. Takes precedence over `project`. Get from list_memory_projects().
@@ -348,53 +287,18 @@ async def edit_note(
"""
# Resolve effective default: allow MCP clients to send null for optional int field
effective_replacements = expected_replacements if expected_replacements is not None else 1
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)
# Resolve or reject routable identifier prefixes before selecting a client.
# Trigger: no explicit project/project_id was provided.
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
# are ambiguous for a mutating tool.
# Outcome: memory:// can route; plain workspace/project/path matches stop with
# guidance instead of silently editing another project.
if project is None and project_id is None:
config = ConfigManager().config
if identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
config,
context=context,
)
elif _workspace_identifier_discovery_available(
# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and project_id is None and identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
config,
) and is_workspace_qualified_plain_identifier(identifier):
detected = await detect_project_from_workspace_identifier_prefix(
identifier,
config,
context=context,
)
if detected:
if output_format == "json":
return {
"title": None,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "AMBIGUOUS_IDENTIFIER",
"project": detected,
}
return _format_ambiguous_workspace_identifier_response(
identifier=identifier,
detected_project=detected,
)
else:
detected = None
ConfigManager().config,
context=context,
)
if detected:
project = detected
@@ -369,11 +369,11 @@ def _format_constrained_text(constrained_project: str) -> str:
return result
async def _resolve_workspace_routing(
async def _resolve_create_project_workspace(
workspace: str | None,
context: Context | None,
) -> str | None:
"""Resolve an optional workspace selector to the routing tenant id."""
"""Resolve the create-project workspace selector to the routing tenant id."""
if workspace is None:
return None
@@ -389,8 +389,7 @@ async def _resolve_workspace_routing(
# a friendly selector such as a slug, name, or tenant id.
# Why: MCP callers should not need to paste UUIDs, but the transport still
# uses X-Workspace-ID with the tenant id as its routing authority.
# Outcome: resolve once before the project-management request and pass only
# the tenant id downstream.
# Outcome: resolve once at create time and pass only the tenant id downstream.
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
return resolved_workspace.tenant_id
@@ -419,8 +418,8 @@ async def create_memory_project(
workspace: Optional cloud workspace selector to create the project in. Slug is
preferred for AI callers, but tenant_id and unique name are also accepted.
When omitted, the connection's default workspace is used. Discover values
via `list_workspaces`. In local mode the selector is passed through
without slug resolution.
via `list_workspaces`. Only meaningful in cloud mode; ignored for local
projects.
output_format: "text" returns the existing human-readable result text.
"json" returns structured project creation metadata.
context: Optional FastMCP context for progress/status logging.
@@ -433,34 +432,31 @@ async def create_memory_project(
create_memory_project("work-notes", "/home/user/work", set_default=True)
create_memory_project("team-notes", "/team/notes", workspace="team-paul")
"""
# Trigger: MCP server is constrained to a single project.
# Why: constrained sessions cannot create projects, and workspace selectors
# may be invalid or unavailable in that locked context.
# Outcome: return the existing disabled response before opening a routed client.
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}"`'
workspace_id = await _resolve_workspace_routing(workspace, context)
workspace_id = await _resolve_create_project_workspace(workspace, context)
# workspace targets a non-default cloud workspace at create time.
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
# Why: there is no project_id yet for per-project routing — the project doesn't exist.
# Outcome: cloud factory routes the create request to the resolved workspace tenant id.
async with get_client(workspace=workspace_id) as client:
# 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
await context.info(f"Creating project: {project_name} at {project_path}")
@@ -540,11 +536,7 @@ async def create_memory_project(
@mcp.tool(
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(
project_name: str,
workspace: str | None = None,
context: Context | None = None,
) -> str:
async def delete_project(project_name: str, context: Context | None = None) -> str:
"""Delete a Basic Memory project.
Removes a project from the configuration and database. This does NOT delete
@@ -553,33 +545,23 @@ async def delete_project(
Args:
project_name: Name of the project to delete
workspace: Optional cloud workspace selector to delete the project from.
Slug is preferred for AI callers, but tenant_id and unique name are
also accepted. When omitted, the connection's default workspace is
used. In local mode the selector is passed through without slug
resolution, matching create_memory_project behavior.
Returns:
Confirmation message about project deletion
Example:
delete_project("old-project")
delete_project("team-project", workspace="team-paul")
Warning:
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
# Trigger: MCP server is constrained to a single project.
# Why: constrained sessions cannot delete projects, and workspace selectors
# may be invalid or unavailable in that locked context.
# Outcome: return the existing disabled message before opening a routed client.
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
workspace_id = await _resolve_workspace_routing(workspace, context)
async with get_client(workspace=workspace_id) as client:
if context: # pragma: no cover
await context.info(f"Deleting project: {project_name}")
+4 -5
View File
@@ -11,7 +11,7 @@ from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_identifier_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -130,10 +130,10 @@ 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.
"""
# Detect project from a memory URL or permalink prefix before routing.
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
detected = await detect_project_from_identifier_prefix(
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
@@ -287,8 +287,7 @@ 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,
"Returning read_note result from resource: {path}", path=entity_path
)
if output_format == "json":
return await _read_json_payload(entity_id)
@@ -187,9 +187,7 @@ async def recent_activity(
# project_id (UUID) takes precedence over project name — without this fallback,
# callers passing only project_id would fall into Discovery Mode.
effective_identifier = project_id if project_id else project
resolved_project = await resolve_project_parameter(
effective_identifier, allow_discovery=True, context=context
)
resolved_project = await resolve_project_parameter(effective_identifier, allow_discovery=True)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
+6 -307
View File
@@ -2,24 +2,18 @@
import re
from textwrap import dedent
from typing import Annotated, List, Optional, Dict, Any, Literal, cast
from uuid import UUID
from typing import Annotated, List, Optional, Dict, Any, Literal
import logfire
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
is_factory_mode,
)
from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_identifier_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -28,7 +22,6 @@ from basic_memory.schemas.search import (
SearchItemType,
SearchQuery,
SearchResponse,
SearchResult,
SearchRetrievalMode,
)
@@ -301,266 +294,6 @@ def _format_search_markdown(result: SearchResponse, project: str, query: str | N
return "\n".join(parts)
def _valid_project_id(value: object) -> str | None:
"""Return a UUID project id string when one is present."""
if not isinstance(value, str) or not value.strip():
return None
try:
return str(UUID(value))
except ValueError:
return None
def _matches_constrained_project(project: dict[str, Any], constrained_project: object) -> bool:
"""Return True when a project list row satisfies BASIC_MEMORY_MCP_PROJECT."""
if not isinstance(constrained_project, str) or not constrained_project.strip():
return True
candidates = {
value
for value in (
project.get("name"),
project.get("qualified_name"),
project.get("external_id"),
)
if isinstance(value, str)
}
return constrained_project in candidates
def _search_project_refs(projects_payload: object) -> list[dict[str, str | None]]:
"""Extract project routing refs for optional account-scoped search."""
if not isinstance(projects_payload, dict):
return []
payload = cast(dict[str, Any], projects_payload)
projects = payload.get("projects")
if not isinstance(projects, list):
return []
refs: list[dict[str, str | None]] = []
seen: set[tuple[str | None, str | None]] = set()
constrained_project = payload.get("constrained_project")
for item in projects:
if not isinstance(item, dict) or not _matches_constrained_project(
item, constrained_project
):
continue
project = item.get("qualified_name") or item.get("name")
project_name = project if isinstance(project, str) and project.strip() else None
project_id = _valid_project_id(item.get("external_id"))
if project_name is None and project_id is None:
continue
key = (project_name, project_id)
if key in seen:
continue
seen.add(key)
refs.append({"project": project_name, "project_id": project_id})
return refs
async def _load_search_project_refs(context: Context | None = None) -> list[dict[str, str | None]]:
"""Load accessible projects for search_all_projects without coupling the wrapper tool."""
from basic_memory.mcp.tools.project_management import list_memory_projects
return _search_project_refs(await list_memory_projects(output_format="json", context=context))
def _raw_results_from_search_payload(
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
) -> list[SearchResult | dict[str, Any]]:
"""Return the result list from any search_notes JSON-compatible payload."""
if isinstance(results, SearchResponse):
return list(results.results)
if isinstance(results, dict):
nested_results = results.get("results")
return (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
return list(results)
def _result_score(result: SearchResult | dict[str, Any]) -> float:
"""Return a comparable search score for merged project results."""
if isinstance(result, SearchResult):
return result.score
score = result.get("score")
return float(score) if isinstance(score, int | float) else 0.0
def _qualify_permalink_for_project(permalink: object, project: str | None) -> object:
"""Return a workspace-qualified permalink when the project ref supplies one."""
if not isinstance(permalink, str) or not permalink.strip():
return permalink
if not isinstance(project, str) or "/" not in project.strip("/"):
return permalink
normalized_permalink = permalink.strip("/")
qualified_project = project.strip("/")
if normalized_permalink == qualified_project or normalized_permalink.startswith(
f"{qualified_project}/"
):
return normalized_permalink
workspace_slug, project_permalink = qualified_project.split("/", 1)
return build_canonical_permalink(
project_permalink,
normalized_permalink,
include_project=True,
workspace_permalink=workspace_slug,
)
def _qualify_results_for_project(
results: list[SearchResult | dict[str, Any]],
project_ref: dict[str, str | None],
) -> list[dict[str, Any]]:
"""Attach the searched workspace/project prefix to each result permalink."""
qualified: list[dict[str, Any]] = []
for result in results:
if isinstance(result, SearchResult):
result_data = result.model_dump()
else:
result_data = dict(result)
result_data["permalink"] = _qualify_permalink_for_project(
result_data.get("permalink"),
project_ref.get("project"),
)
qualified.append(result_data)
return qualified
def _result_total(results: dict[str, Any], raw_results: list[SearchResult | dict[str, Any]]) -> int:
"""Return the best available total for a per-project search payload."""
total = results.get("total")
if isinstance(total, int) and total > 0:
return total
return len(raw_results) + (1 if results.get("has_more") is True else 0)
def _project_ref_label(project_ref: dict[str, str | None]) -> str:
"""Return a stable log label for a project search ref."""
return project_ref.get("project") or project_ref.get("project_id") or "<unknown project>"
async def _search_all_projects(
*,
query: str | None,
page: int,
page_size: int,
search_type: str | None,
output_format: Literal["text", "json"],
note_types: list[str],
entity_types: list[str],
after_date: str | None,
metadata_filters: dict[str, Any] | None,
tags: list[str] | None,
status: str | None,
min_similarity: float | None,
context: Context | None,
) -> dict | str:
"""Search every accessible project when the caller explicitly opts in."""
requested_page = max(page, 1)
requested_page_size = max(page_size, 1)
project_refs = await _load_search_project_refs(context=context)
if not project_refs:
response = SearchResponse(
results=[],
current_page=requested_page,
page_size=requested_page_size,
total=0,
has_more=False,
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
per_project_page_size = requested_page * requested_page_size
merged_results: list[dict[str, Any]] = []
total = 0
any_project_has_more = False
# Trigger: caller asked for an account-wide search.
# Why: project_id (external UUID) routes through the cloud v2 API path,
# which 401s on local installs because there's no JWT to present.
# Project names route through the local-ASGI path and work for both
# backends — cloud disambiguates names via the workspace/project
# qualified_name already baked into project_ref["project"].
# Outcome: forward project_id only when the same signals get_project_client
# uses to pick a cloud route are present. Mirrors the cloud_available
# composite in project_context.get_project_client (single source of
# truth for "can we route to cloud?").
config = ConfigManager().config
use_cloud_routing = (
is_factory_mode()
or (_explicit_routing() and not _force_local_mode())
or has_cloud_credentials(config)
)
for project_ref in project_refs:
recursive_project_id = project_ref["project_id"] if use_cloud_routing else None
try:
results = await search_notes(
query=query,
project=project_ref["project"],
project_id=recursive_project_id,
page=1,
page_size=per_project_page_size,
search_type=search_type,
output_format="json",
note_types=note_types or None,
entity_types=entity_types or None,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
search_all_projects=False,
context=context,
)
except Exception as exc:
logger.warning(
f"Multi-project search failed for project {_project_ref_label(project_ref)}: {exc}"
)
continue
if isinstance(results, str):
if not results.startswith("# Search Failed"):
return results
logger.warning(
"Multi-project search failed for project "
f"{_project_ref_label(project_ref)}: {results}"
)
continue
raw_results = _raw_results_from_search_payload(results)
total += _result_total(results, raw_results)
any_project_has_more = any_project_has_more or results.get("has_more") is True
merged_results.extend(_qualify_results_for_project(raw_results, project_ref))
sorted_results = sorted(merged_results, key=_result_score, reverse=True)
start = (requested_page - 1) * requested_page_size
end = start + requested_page_size
paged_results = sorted_results[start:end]
response = SearchResponse.model_validate(
{
"results": paged_results,
"current_page": requested_page,
"page_size": requested_page_size,
"total": total,
"has_more": any_project_has_more or total > end or len(sorted_results) > end,
}
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
# TODO: re-enable once MCP client rendering is working
@@ -576,13 +309,6 @@ async def search_notes(
] = None,
project: Optional[str] = None,
project_id: Optional[str] = None,
search_all_projects: Annotated[
bool,
Field(
default=False,
validation_alias=AliasChoices("search_all_projects", "all_projects"),
),
] = False,
# `offset` is intentionally NOT aliased to `page`: offset is item-indexed
# (skip N items) while page is 1-indexed page-number. Direct aliasing would
# silently return the wrong slice.
@@ -647,8 +373,6 @@ async def search_notes(
Project Resolution:
Server resolves projects in this order: Single Project Mode project parameter default project.
If project unknown, use list_memory_projects() or recent_activity() first.
Set search_all_projects=True to search every accessible project; this is opt-in because it
performs one search per project.
## Search Syntax Examples
@@ -724,8 +448,6 @@ async def search_notes(
project_id: Project external_id (UUID). Prefer this over `project` when known
it routes to the exact project regardless of name collisions across cloud
workspaces. Takes precedence over `project`. Get from list_memory_projects().
search_all_projects: Optional opt-in to search every accessible project. Ignored when
`project` or `project_id` is supplied.
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:
@@ -829,10 +551,10 @@ async def search_notes(
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
query = remainder or None
# Detect project from a memory URL or permalink prefix before routing.
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None and query is not None:
detected = await detect_project_from_identifier_prefix(
detected = await detect_project_from_memory_url_prefix(
query,
ConfigManager().config,
context=context,
@@ -840,35 +562,12 @@ async def search_notes(
if detected:
project = detected
# Trigger: caller explicitly requests account/workspace-wide search and did not
# already provide a concrete project route.
# Why: multi-project fan-out can be slow, so default search remains project-scoped.
# Outcome: run one normal search per accessible project and merge ranked results.
if search_all_projects and project is None and project_id is None:
all_projects_result = await _search_all_projects(
query=query,
page=page,
page_size=page_size,
search_type=search_type,
output_format=output_format,
note_types=note_types,
entity_types=entity_types,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
context=context,
)
return all_projects_result
with logfire.span(
"mcp.tool.search_notes",
entrypoint="mcp",
tool_name="search_notes",
requested_project=project,
requested_project_id=project_id,
search_all_projects=search_all_projects,
search_type=search_type or "default",
output_format=output_format,
page=page,
+6 -22
View File
@@ -8,7 +8,7 @@ import typing
from typing import Any, Optional
import logfire
from httpx import Response, URL, AsyncClient, HTTPStatusError, Headers
from httpx import Response, URL, AsyncClient, HTTPStatusError
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
from httpx._types import (
RequestContent,
@@ -58,22 +58,6 @@ def _transport_error_span_attrs(exc: Exception) -> dict[str, Any]:
}
def _request_headers(headers: HeaderTypes | None) -> HeaderTypes | None:
"""Merge request-local workspace permalink headers into outbound API calls."""
from basic_memory.workspace_context import workspace_permalink_headers
workspace_headers = workspace_permalink_headers()
if not workspace_headers:
return headers
if headers is None:
return workspace_headers
merged_headers = Headers(headers)
merged_headers.update(workspace_headers)
return merged_headers
def get_error_message(
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
) -> str:
@@ -240,7 +224,7 @@ async def call_get(
response = await client.get(
url,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
@@ -344,7 +328,7 @@ async def call_put(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
@@ -448,7 +432,7 @@ async def call_patch(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
@@ -558,7 +542,7 @@ async def call_post(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
@@ -683,7 +667,7 @@ async def call_delete(
response = await client.delete(
url=url,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
+1 -5
View File
@@ -66,14 +66,10 @@ async def write_note(
Relations format:
- Explicit: `- relation_type [[Entity]] (optional context)`
- Quoted: `- "multi word relation type" [[Entity]] (optional context)`
- Quoted: `- 'multi word relation type' [[Entity]] (optional context)`
- Inline: Any other `[[Entity]]` reference creates a `links_to` relation
- Inline: Any `[[Entity]]` reference creates a relation
Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- "based on" [[Design Notes]]`
`- 'in response to' [[Incident Review]]`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
@@ -5,10 +5,10 @@ from typing import List, Optional, Sequence, Union, Any
from loguru import logger
from sqlalchemy import exists, func, select
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import load_only, selectinload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from sqlalchemy.engine import Row
@@ -178,24 +178,6 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Entity]:
"""Fetch minimal entity fields needed for context hydration.
Context hydration only needs an entity's primary key, title, and external
UUID. Keeping this separate from find_by_ids avoids the relationship eager
loads that are useful for full entity reads but expensive for response shaping.
"""
if not ids:
return []
query = (
self.select()
.where(Entity.id.in_(ids))
.options(load_only(Entity.id, Entity.title, Entity.external_id))
)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_permalink_to_file_path_map(self) -> dict[str, str]:
"""Get a mapping of permalink -> file_path for all entities.
@@ -454,22 +436,6 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def find_without_relations(self) -> Sequence[Entity]:
"""Find entities that have no incoming or outgoing relations."""
# Trigger: entity appears as a source in any relation.
# Why: even unresolved outgoing links mean the entity references another node.
# Outcome: entities with outgoing relations are excluded from the orphan list.
has_outgoing = exists().where(Relation.from_id == Entity.id)
# Trigger: entity appears as the resolved target in any relation.
# Why: only resolved relation targets are graph nodes with an incoming edge.
# Outcome: entities referenced by resolved links are excluded from orphans.
has_incoming = exists().where(Relation.to_id == Entity.id)
query = self.select().where(~has_outgoing).where(~has_incoming).order_by(Entity.file_path)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_distinct_directories(self) -> List[str]:
"""Extract unique directory paths from file_path column.
@@ -5,17 +5,11 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
import re
from typing import Any, Iterable, List, cast
from typing import Any, Iterable, List
_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
_NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$")
_COMPARISON_OPERATORS = {
"$gt": "gt",
"$gte": "gte",
"$lt": "lt",
"$lte": "lte",
}
@dataclass(frozen=True)
@@ -54,11 +48,6 @@ def _normalize_scalar(value: Any) -> Any:
return value
def _normalize_numeric(value: object) -> float:
"""Normalize a value already proven numeric by _is_numeric_value."""
return float(cast(str | int | float, value))
def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter]:
"""Parse metadata filters into normalized clauses.
@@ -84,12 +73,7 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
if isinstance(raw_value, dict):
if len(raw_value) != 1:
raise ValueError(f"Invalid metadata filter for '{raw_key}': {raw_value}")
raw_op, value = next(iter(raw_value.items()))
if not isinstance(raw_op, str):
raise ValueError(
f"Unsupported operator '{raw_op}' in metadata filter for '{raw_key}'"
)
op = raw_op
op, value = next(iter(raw_value.items()))
if op == "$in":
if not isinstance(value, list) or not value:
@@ -99,20 +83,15 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
)
continue
if op in _COMPARISON_OPERATORS:
if op in {"$gt", "$gte", "$lt", "$lte"}:
if _is_numeric_value(value):
normalized = _normalize_numeric(value)
normalized = float(value)
comparison = "numeric"
else:
normalized = _normalize_scalar(value)
comparison = "text"
parsed.append(
ParsedMetadataFilter(
path_parts,
_COMPARISON_OPERATORS[op],
normalized,
comparison,
)
ParsedMetadataFilter(path_parts, op.lstrip("$"), normalized, comparison)
)
continue
@@ -120,7 +99,7 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
if not isinstance(value, list) or len(value) != 2:
raise ValueError(f"$between requires [min, max] for '{raw_key}'")
if _is_numeric_collection(value):
normalized = [_normalize_numeric(v) for v in value]
normalized = [float(v) for v in value]
comparison = "numeric"
else:
normalized = [_normalize_scalar(v) for v in value]
@@ -705,6 +705,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str, str]:
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
@@ -753,14 +755,28 @@ class PostgresSearchRepository(SearchRepositoryBase):
else:
conditions.append("search_index.permalink = :permalink")
# Handle search item type filter (parameterized for defense-in-depth)
if search_item_types:
type_placeholders = []
for idx, t in enumerate(search_item_types):
param_name = f"search_type_{idx}"
params[param_name] = t.value
type_placeholders.append(f":{param_name}")
conditions.append(f"search_index.type IN ({', '.join(type_placeholders)})")
# Handle typed search row filters (parameterized for defense-in-depth)
self._append_in_filter(
conditions,
params,
column="search_index.type",
values=search_item_types,
param_prefix="search_type",
)
self._append_in_filter(
conditions,
params,
column="search_index.category",
values=observation_categories,
param_prefix="observation_category",
)
self._append_in_filter(
conditions,
params,
column="search_index.relation_type",
values=relation_types,
param_prefix="relation_type",
)
# Handle note type filter using JSONB containment (parameterized)
if note_types:
@@ -774,8 +790,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
# Handle date filter
if after_date:
params["after_date"] = after_date
# Filter on updated_at so recently-edited notes are included even when created_at is old
conditions.append("search_index.updated_at > :after_date")
conditions.append("search_index.created_at > :after_date")
# order by most recent first
order_by_clause = ", search_index.updated_at DESC"
@@ -879,6 +894,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -895,6 +912,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -919,6 +938,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
)
@@ -946,7 +967,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
{score_expr} as score
FROM {from_clause}
WHERE {where_clause}
ORDER BY score DESC {order_by_clause}, search_index.id ASC
ORDER BY score DESC, search_index.id ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
@@ -1008,6 +1029,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -1022,6 +1045,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -1041,6 +1066,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
)
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
@@ -4,9 +4,7 @@ from pathlib import Path
from typing import Optional, Sequence, Union
from loguru import logger
from sqlalchemy import inspect as sa_inspect, select, text
from sqlalchemy.exc import NoResultFound, OperationalError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
@@ -14,65 +12,6 @@ from basic_memory.models.project import Project
from basic_memory.repository.repository import Repository
async def _load_sqlite_vec_on_session(session) -> bool:
"""Ensure the sqlite-vec extension is loaded on this session's connection.
Returns True when vec0 is available after the call. Returns False when the
extension can't be loaded on this Python build (e.g., python.org macOS or
Windows interpreters without `enable_load_extension`) every connection in
the pool shares the same interpreter, so a False here also means no
embedding row could ever have been written, and skipping the embeddings
purge is safe.
Mirrors SQLiteSearchRepository._ensure_sqlite_vec_loaded but as a free
function: we don't have a SearchRepository instance during project delete,
and the per-connection nature of extension loading means a pooled connection
routed to this session might not have vec loaded even when other
connections wrote embeddings.
"""
try:
await session.execute(text("SELECT vec_version()"))
return True
except OperationalError:
pass
try:
import sqlite_vec # type: ignore[import-not-found]
except ImportError:
logger.debug("sqlite-vec package not installed; skipping vec purge")
return False
async_connection = await session.connection()
raw_connection = await async_connection.get_raw_connection()
driver_connection = raw_connection.driver_connection
if not hasattr(driver_connection, "enable_load_extension"):
# Trigger: CPython build without sqlite extension support (#711).
# Why: load_extension is unavailable, so no connection in this pool
# can host vec0. No embeddings exist anywhere.
# Outcome: skip the embeddings purge entirely.
logger.debug(
"Skipping search_vector_embeddings purge: this Python build does "
"not support SQLite extension loading"
)
return False
try:
await driver_connection.enable_load_extension(True)
await driver_connection.load_extension(sqlite_vec.loadable_path())
await driver_connection.enable_load_extension(False)
await session.execute(text("SELECT vec_version()"))
except Exception as exc:
logger.warning(
"Failed to load sqlite-vec for project delete cleanup; "
"skipping embeddings purge: {}",
exc,
)
return False
return True
class ProjectRepository(Repository[Project]):
"""Repository for Project model.
@@ -182,83 +121,6 @@ class ProjectRepository(Repository[Project]):
return target_project
return None # pragma: no cover
async def delete(self, entity_id: int) -> bool:
"""Delete a project and its derived search rows in one transaction.
The cascade picture differs by backend:
- search_index project: Postgres has ON DELETE CASCADE FK; SQLite
stores search_index as an FTS5 virtual table and can't carry FKs,
so it needs explicit cleanup.
- search_vector_chunks project: neither backend has an FK here, so
both need an explicit DELETE.
- search_vector_embeddings search_vector_chunks: Postgres has an FK
(chunk_id REFERENCES ON DELETE CASCADE); SQLite stores embeddings
in a vec0 virtual table keyed by rowid with no cascade. On SQLite
the embeddings must be purged before the chunk rows, otherwise
`_run_vector_query` keeps returning stale vectors that crowd out
live results.
Each derived table is created lazily (search_index by
SearchRepository.init_search_index, the vector tables once semantic
search initializes), so any of them may be absent on minimal test DBs.
Inspect the connection once and skip whichever is missing.
"""
logger.debug(f"Deleting Project and search rows for project_id: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
project = result.scalars().one()
except NoResultFound:
logger.debug(f"No Project found to delete: {entity_id}")
return False
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
is_sqlite = dialect_name == "sqlite"
existing_tables = await session.run_sync(
lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names())
)
# search_index: SQLite has no FK on the FTS5 virtual table; Postgres
# cascades from the project FK, so the explicit DELETE is redundant.
if is_sqlite and "search_index" in existing_tables:
await session.execute(
text("DELETE FROM search_index WHERE project_id = :project_id"),
{"project_id": entity_id},
)
# search_vector_chunks: no FK to project on either backend, so both
# backends need this. SQLite must purge vec0 embeddings first
# (rowid pseudocolumn — Postgres uses chunk_id and would 500 here);
# Postgres' chunk_id FK CASCADE handles its embeddings cleanup when
# we delete the chunk rows below.
if "search_vector_chunks" in existing_tables:
if is_sqlite and "search_vector_embeddings" in existing_tables:
# Extension loading is per-connection. We must load vec0 on
# *this* session before the DELETE; otherwise a different
# pooled connection might have written embeddings that we'd
# silently leave behind.
if await _load_sqlite_vec_on_session(session):
await session.execute(
text(
"DELETE FROM search_vector_embeddings WHERE rowid IN ("
"SELECT id FROM search_vector_chunks "
"WHERE project_id = :project_id)"
),
{"project_id": entity_id},
)
await session.execute(
text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"),
{"project_id": entity_id},
)
await session.delete(project)
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
return True
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.
@@ -41,6 +41,8 @@ class SearchRepository(Protocol):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -59,6 +61,8 @@ class SearchRepository(Protocol):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -218,6 +218,8 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -234,6 +236,8 @@ class SearchRepositoryBase(ABC):
note_types: Filter by note types (from metadata.note_type)
after_date: Filter by created_at > after_date
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
observation_categories: Filter observation rows by category
relation_types: Filter relation rows by relation_type
metadata_filters: Structured frontmatter metadata filters
limit: Maximum results to return
offset: Number of results to skip
@@ -256,6 +260,8 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -473,6 +479,26 @@ class SearchRepositoryBase(ABC):
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
return result
@staticmethod
def _append_in_filter(
conditions: list[str],
params: dict[str, Any],
*,
column: str,
values: list[Any] | None,
param_prefix: str,
) -> None:
"""Append a parameterized SQL IN clause for controlled column names."""
if not values:
return
placeholders: list[str] = []
for idx, value in enumerate(values):
param_name = f"{param_prefix}_{idx}"
params[param_name] = value.value if isinstance(value, SearchItemType) else value
placeholders.append(f":{param_name}")
conditions.append(f"{column} IN ({', '.join(placeholders)})")
async def delete_entity_vector_rows(self, entity_id: int) -> None:
"""Delete one entity's derived vector rows using the backend's cleanup path."""
await self._ensure_vector_tables()
@@ -1777,6 +1803,8 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
observation_categories: Optional[List[str]],
relation_types: Optional[List[str]],
metadata_filters: Optional[dict],
retrieval_mode: SearchRetrievalMode,
min_similarity: Optional[float] = None,
@@ -1810,6 +1838,8 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=limit,
@@ -1829,6 +1859,8 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=limit,
@@ -1858,6 +1890,8 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
observation_categories: Optional[List[str]],
relation_types: Optional[List[str]],
metadata_filters: Optional[dict],
min_similarity: Optional[float] = None,
limit: int,
@@ -1968,6 +2002,8 @@ class SearchRepositoryBase(ABC):
note_types,
after_date,
search_item_types,
observation_categories,
relation_types,
metadata_filters,
]
)
@@ -1981,6 +2017,8 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=SearchRetrievalMode.FTS,
limit=VECTOR_FILTER_SCAN_LIMIT,
@@ -2131,6 +2169,8 @@ class SearchRepositoryBase(ABC):
note_types: Optional[List[str]],
after_date: Optional[datetime],
search_item_types: Optional[List[SearchItemType]],
observation_categories: Optional[List[str]],
relation_types: Optional[List[str]],
metadata_filters: Optional[dict],
min_similarity: Optional[float] = None,
limit: int,
@@ -2155,6 +2195,8 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=SearchRetrievalMode.FTS,
limit=candidate_limit,
@@ -2170,6 +2212,8 @@ class SearchRepositoryBase(ABC):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
min_similarity=min_similarity,
limit=candidate_limit,
@@ -714,6 +714,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str]:
"""Build SQLite FTS FROM/WHERE params shared by search and count."""
@@ -766,14 +768,28 @@ class SQLiteSearchRepository(SearchRepositoryBase):
params["permalink"] = permalink_text
match_conditions.append("search_index.permalink MATCH :permalink")
# Handle entity type filter (parameterized for defense-in-depth)
if search_item_types:
type_placeholders = []
for idx, t in enumerate(search_item_types):
param_name = f"search_type_{idx}"
params[param_name] = t.value
type_placeholders.append(f":{param_name}")
conditions.append(f"search_index.type IN ({', '.join(type_placeholders)})")
# Handle typed search row filters (parameterized for defense-in-depth)
self._append_in_filter(
conditions,
params,
column="search_index.type",
values=search_item_types,
param_prefix="search_type",
)
self._append_in_filter(
conditions,
params,
column="search_index.category",
values=observation_categories,
param_prefix="observation_category",
)
self._append_in_filter(
conditions,
params,
column="search_index.relation_type",
values=relation_types,
param_prefix="relation_type",
)
# Handle note type filter (frontmatter type field, parameterized)
if note_types:
@@ -789,8 +805,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
# Handle date filter using datetime() for proper comparison
if after_date:
params["after_date"] = after_date
# Filter on updated_at so recently-edited notes are included even when created_at is old
conditions.append("datetime(search_index.updated_at) > datetime(:after_date)")
conditions.append("datetime(search_index.created_at) > datetime(:after_date)")
# order by most recent first
order_by_clause = ", search_index.updated_at DESC"
@@ -906,6 +921,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -922,6 +939,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -940,6 +959,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
)
@@ -1027,6 +1048,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
observation_categories: Optional[List[str]] = None,
relation_types: Optional[List[str]] = None,
metadata_filters: Optional[dict] = None,
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
@@ -1041,6 +1064,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
@@ -1054,6 +1079,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
observation_categories=observation_categories,
relation_types=relation_types,
metadata_filters=metadata_filters,
)
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
+20 -67
View File
@@ -8,9 +8,7 @@ Syntax reference:
field: type, description # required field
field?: type, description # optional field
field(array): type # array of values
field(array, description): type # array with description
field?(enum): [val1, val2] # enumeration
field?(enum, description): [val1, val2] # enum with description
field?(object): # nested object
sub_field: type
EntityName as type (capitalized) # entity reference
@@ -60,88 +58,46 @@ class SchemaDefinition:
# with an uppercase letter is treated as an entity reference.
SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean", "any"})
MODIFIER_TYPES = frozenset({"array", "enum", "object"})
# --- Field Name Parsing ---
def _parse_field_key_parts(key: str) -> tuple[str, bool, bool, bool, bool, str | None]:
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
"""Parse a Picoschema field key into its components.
Returns (name, required, is_array, is_enum, is_object, description).
The key format is: name[?][(array|enum|object[, description])]
Returns (name, required, is_array, is_enum, is_object).
The key format is: name[?][(array|enum|object)]
Examples:
"name" -> ("name", True, False, False, False, None)
"role?" -> ("role", False, False, False, False, None)
"tags?(array)" -> ("tags", False, True, False, False, None)
"tags?(array, labels)" -> ("tags", False, True, False, False, "labels")
"status?(enum)" -> ("status", False, False, True, False, None)
"metadata?(object)" -> ("metadata", False, False, False, True, None)
"name" -> ("name", True, False, False)
"role?" -> ("role", False, False, False)
"tags?(array)" -> ("tags", False, True, False)
"status?(enum)" -> ("status", False, False, True)
"metadata?(object)" -> ("metadata", False, False, False) + children
"""
required = True
is_array = False
is_enum = False
is_object = False
description = None
key, modifier, description = _split_modifier_suffix(key)
if modifier == "array":
# Check for modifier suffix: (array), (enum), (object)
if key.endswith("(array)"):
is_array = True
elif modifier == "enum":
key = key[: -len("(array)")]
elif key.endswith("(enum)"):
is_enum = True
elif modifier == "object":
key = key[: -len("(enum)")]
elif key.endswith("(object)"):
is_object = True
key = key[: -len("(object)")]
# Check for optional marker
if key.endswith("?"):
required = False
key = key[:-1]
return key.strip(), required, is_array, is_enum, is_object, description
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
"""Parse a Picoschema field key, discarding any modifier description."""
name, required, is_array, is_enum, is_object, _description = _parse_field_key_parts(key)
return name, required, is_array, is_enum, is_object
def _split_modifier_suffix(key: str) -> tuple[str, str | None, str | None]:
"""Split a trailing picoschema modifier from a field key."""
stripped_key = key.rstrip()
if not stripped_key.endswith(")"):
return key, None, None
# Trigger: field names and modifier descriptions may both contain parentheses
# Why: only the parenthesis paired with the final suffix can introduce a modifier
# Outcome: preserves names like "risk(score)" and descriptions like "labels (freeform)"
open_paren_index = -1
depth = 0
for index in range(len(stripped_key) - 1, -1, -1):
char = stripped_key[index]
if char == ")":
depth += 1
elif char == "(":
depth -= 1
if depth == 0:
open_paren_index = index
break
if open_paren_index == -1:
return key, None, None
modifier_text = stripped_key[open_paren_index + 1 : -1].strip()
modifier, separator, description = modifier_text.partition(",")
modifier = modifier.strip()
if modifier not in MODIFIER_TYPES:
return key, None, None
key_without_modifier = stripped_key[:open_paren_index].rstrip()
parsed_description = description.strip() if separator else None
return key_without_modifier, modifier, parsed_description or None
return key, required, is_array, is_enum, is_object
def _parse_type_and_description(value: str) -> tuple[str, str | None]:
@@ -214,7 +170,7 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
fields: list[SchemaField] = []
for key, value in yaml_dict.items():
name, required, is_array, is_enum, is_object, key_description = _parse_field_key_parts(key)
name, required, is_array, is_enum, is_object = _parse_field_key(key)
# --- Enum fields ---
# Trigger: value is a list or a string containing bracketed enum values
@@ -223,12 +179,11 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
# in YAML to avoid parse errors)
# Outcome: SchemaField with is_enum=True and enum_values populated
if is_enum:
description = key_description
description = None
if isinstance(value, list):
enum_values = [str(v) for v in value]
else:
enum_values, value_description = _parse_enum_string(str(value))
description = description or value_description
enum_values, description = _parse_enum_string(str(value))
fields.append(
SchemaField(
name=name,
@@ -252,15 +207,13 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
name=name,
type="object",
required=required,
description=key_description,
children=children,
)
)
continue
# --- Scalar and entity ref fields ---
type_str, value_description = _parse_type_and_description(str(value))
description = key_description or value_description
type_str, description = _parse_type_and_description(str(value))
is_entity_ref = _is_entity_ref_type(type_str)
fields.append(
-45
View File
@@ -88,51 +88,6 @@ class WorkspaceListResponse(BaseModel):
)
def workspace_matches_exact_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
"""Return True when identifier matches workspace tenant_id, slug, or name."""
if workspace.tenant_id == identifier:
return True
if workspace.slug.casefold() == identifier.casefold():
return True
return workspace.name.casefold() == identifier.casefold()
def workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
"""Return True when identifier matches workspace tenant_id, slug, name, or type."""
return (
workspace_matches_exact_identifier(workspace, identifier)
or workspace.workspace_type.casefold() == identifier.casefold()
)
def format_workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
"""Format deterministic workspace choices for prompt-style errors."""
return "\n".join(
[
(
f"- {item.name} "
f"(slug={item.slug}, type={item.workspace_type}, "
f"role={item.role}, tenant_id={item.tenant_id})"
)
for item in workspaces
]
)
def format_workspace_selection_choices(workspaces: list[WorkspaceInfo]) -> str:
"""Format matching workspaces with copyable unique identifiers first."""
return "\n".join(
[
(
f"- {item.name} ({item.workspace_type}, role={item.role})\n"
f" workspace: {item.slug}\n"
f" tenant_id: {item.tenant_id}"
)
for item in workspaces
]
)
class CloudProjectIndexStatus(BaseModel):
"""Index freshness summary for one cloud project."""
+8
View File
@@ -46,6 +46,8 @@ class SearchQuery(BaseModel):
- metadata_filters: Structured frontmatter filters (field -> value)
- tags: Convenience frontmatter tag filter
- status: Convenience frontmatter status filter
- observation_categories: Limit observation results to categories
- relation_types: Limit relation results to relationship types
Boolean search examples:
- "python AND flask" - Find items with both terms
@@ -67,6 +69,8 @@ class SearchQuery(BaseModel):
metadata_filters: Optional[dict[str, Any]] = None # Structured frontmatter filters
tags: Optional[List[str]] = None # Convenience tag filter
status: Optional[str] = None # Convenience status filter
observation_categories: Optional[List[str]] = None # Filter observations by category
relation_types: Optional[List[str]] = None # Filter relations by relation_type
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS
min_similarity: Optional[float] = None # Per-query override for semantic_min_similarity
@@ -85,6 +89,8 @@ class SearchQuery(BaseModel):
status_is_empty = self.status is None or (isinstance(self.status, str) and not self.status)
note_types_is_empty = not self.note_types
entity_types_is_empty = not self.entity_types
observation_categories_is_empty = not self.observation_categories
relation_types_is_empty = not self.relation_types
return (
self.permalink is None
and self.permalink_match is None
@@ -96,6 +102,8 @@ class SearchQuery(BaseModel):
and metadata_is_empty
and tags_is_empty
and status_is_empty
and observation_categories_is_empty
and relation_types_is_empty
)
def has_boolean_operators(self) -> bool:
-2
View File
@@ -14,7 +14,6 @@ from basic_memory.schemas.v2.graph import (
GraphEdge,
GraphNode,
GraphResponse,
OrphanEntitiesResponse,
)
from basic_memory.schemas.v2.resource import (
CreateResourceRequest,
@@ -34,7 +33,6 @@ __all__ = [
"GraphEdge",
"GraphNode",
"GraphResponse",
"OrphanEntitiesResponse",
"CreateResourceRequest",
"UpdateResourceRequest",
"ResourceResponse",
-9
View File
@@ -29,12 +29,3 @@ class GraphResponse(BaseModel):
edges: list[GraphEdge] = Field(
default_factory=list, description="All resolved relations as edges"
)
class OrphanEntitiesResponse(BaseModel):
"""Entities that have no incoming or outgoing relations in the knowledge graph."""
entities: list[GraphNode] = Field(
default_factory=list, description="Entities with no relations"
)
total: int = Field(..., description="Total count of orphan entities")
+4 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple, TYPE_CHECKING
from typing import List, Optional, Tuple, TYPE_CHECKING
from loguru import logger
@@ -307,7 +307,7 @@ class ContextService:
entity_id_values = ", ".join([str(i) for i in entity_ids])
# Parameters for bindings - include project_id for security filtering
params: dict[str, Any] = {
params = {
"max_depth": max_depth,
"max_results": max_results,
"project_id": self.search_repository.project_id,
@@ -322,9 +322,9 @@ class ContextService:
since_utc = (
since.astimezone(timezone.utc) if since.tzinfo else since
) # pragma: no cover
params["since_date"] = since_utc.replace(tzinfo=None) # pragma: no cover
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
else:
params["since_date"] = since.isoformat()
params["since_date"] = since.isoformat() # pyright: ignore
date_filter = "AND e.created_at >= :since_date"
relation_date_filter = "AND e_from.created_at >= :since_date"
timeframe_condition = "AND eg.relation_date >= :since_date"
+3 -3
View File
@@ -1042,9 +1042,9 @@ class EntityService(BaseService[EntityModel]):
for rel, resolved in zip(markdown.relations, resolved_entities):
# Handle exceptions from gather and None results
target_entity: Optional[Entity] = None
if not isinstance(resolved, BaseException):
# asyncio.gather(..., return_exceptions=True) can return any BaseException.
target_entity = resolved
if not isinstance(resolved, Exception):
# Type narrowing: resolved is Optional[Entity] here, not Exception
target_entity = resolved # pyright: ignore [reportAssignmentType]
if target_entity is None and not resolve_targets:
target_entity = await self._resolve_deferred_self_relation(rel.target, entity)
+21 -70
View File
@@ -1,7 +1,7 @@
"""Service and helpers for resolving markdown links and permalink-like identifiers."""
"""Service for resolving markdown links to permalinks."""
import uuid as uuid_mod
from typing import Any, Optional, Tuple, Dict
from typing import Optional, Tuple, Dict
from loguru import logger
@@ -13,59 +13,10 @@ from basic_memory.repository.search_repository import create_search_repository
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
from basic_memory.utils import (
build_permalink_resolution_candidates,
build_canonical_permalink,
generate_permalink,
normalize_project_reference,
)
from basic_memory.workspace_context import current_workspace_permalink_context
def is_workspace_qualified_plain_identifier(identifier: str) -> bool:
"""Return True for plain ``<workspace>/<project>/<path>`` identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://"):
return False
normalized = normalize_project_reference(stripped).strip("/")
return len(normalized.split("/", 2)) == 3
async def detect_project_from_workspace_identifier_prefix(
identifier: str,
config: BasicMemoryConfig,
context: Any | None = None,
) -> Optional[str]:
"""Resolve a project route from a plain workspace-qualified identifier."""
if not is_workspace_qualified_plain_identifier(identifier):
return None
from basic_memory.mcp.project_context import (
_workspace_identifier_discovery_available,
resolve_workspace_qualified_identifier,
)
if not _workspace_identifier_discovery_available(identifier, config):
return None
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
if workspace_resolution is None:
return None
return workspace_resolution.project_identifier
class LinkResolver:
@@ -238,25 +189,25 @@ class LinkResolver:
"""Resolve a link within a specific project scope."""
clean_text = link_text
include_project = self._include_project_permalinks()
workspace_context = current_workspace_permalink_context()
workspace_permalink = (
workspace_context.workspace_slug
if workspace_context and workspace_context.should_prefix_permalinks
else None
)
# Trigger: callers can pass title, short permalink, project/path, or
# workspace/project/path identifiers to the same resolver.
# Why: search results and memory:// URLs should stay usable across read,
# edit, delete, move, and API-level entity resolution.
# Outcome: resolve canonical workspace IDs and legacy project-prefixed rows
# through one shared candidate builder.
permalink_candidates = build_permalink_resolution_candidates(
clean_text,
project_permalink,
include_project=include_project,
workspace_permalink=workspace_permalink,
)
canonical_permalink: Optional[str] = None
legacy_permalink: Optional[str] = None
# Trigger: permalinks include project slug and project permalink is known
# Why: support globally addressable permalinks while keeping legacy links resolvable
# Outcome: include canonical and legacy candidates for resolution
if include_project and project_permalink:
canonical_permalink = build_canonical_permalink(
project_permalink, clean_text, include_project=True
)
if clean_text.startswith(f"{project_permalink}/"):
legacy_candidate = clean_text.removeprefix(f"{project_permalink}/")
if legacy_candidate:
legacy_permalink = legacy_candidate
permalink_candidates = []
for candidate in (clean_text, canonical_permalink, legacy_permalink):
if candidate and candidate not in permalink_candidates:
permalink_candidates.append(candidate)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
@@ -75,6 +75,8 @@ class _PreparedSearchQuery:
title: str | None
note_types: list[str] | None
search_item_types: list[SearchItemType] | None
observation_categories: list[str] | None
relation_types: list[str] | None
after_date: datetime | None
metadata_filters: dict[str, Any] | None
retrieval_mode: SearchRetrievalMode
@@ -190,6 +192,8 @@ class SearchService:
title=query.title,
note_types=query.note_types,
search_item_types=query.entity_types,
observation_categories=query.observation_categories,
relation_types=query.relation_types,
after_date=after_date,
metadata_filters=metadata_filters,
retrieval_mode=query.retrieval_mode or SearchRetrievalMode.FTS,
@@ -203,6 +207,8 @@ class SearchService:
or prepared.title
or prepared.note_types
or prepared.search_item_types
or prepared.observation_categories
or prepared.relation_types
or prepared.after_date
or prepared.metadata_filters
)
@@ -217,6 +223,8 @@ class SearchService:
prepared.metadata_filters
or prepared.note_types
or prepared.search_item_types
or prepared.observation_categories
or prepared.relation_types
or prepared.after_date
)
@@ -235,6 +243,8 @@ class SearchService:
title=prepared.title,
note_types=prepared.note_types,
search_item_types=prepared.search_item_types,
observation_categories=prepared.observation_categories,
relation_types=prepared.relation_types,
after_date=prepared.after_date,
metadata_filters=prepared.metadata_filters,
retrieval_mode=prepared.retrieval_mode,
@@ -256,6 +266,8 @@ class SearchService:
title=prepared.title,
note_types=prepared.note_types,
search_item_types=prepared.search_item_types,
observation_categories=prepared.observation_categories,
relation_types=prepared.relation_types,
after_date=prepared.after_date,
metadata_filters=prepared.metadata_filters,
retrieval_mode=prepared.retrieval_mode,
+6 -38
View File
@@ -1530,36 +1530,12 @@ class SyncService:
count += 1
return count
# Trigger: large-project scan optimization needs the OS `find` command.
# Why: passing argv directly avoids shell interpretation of configured project paths.
# Outcome: quotes and shell metacharacters in paths are treated as data.
process = await asyncio.create_subprocess_exec(
"find",
str(directory),
"-type",
"f",
"-print0",
process = await asyncio.create_subprocess_shell(
f'find "{directory}" -type f | wc -l',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
count = 0
stderr_task = None
if process.stderr is not None:
stderr_task = asyncio.create_task(process.stderr.read())
if process.stdout is None:
await process.wait()
else:
# Trigger: `find` can emit one path per file for very large projects.
# Why: collecting every path via communicate() scales memory with path bytes.
# Outcome: count null-delimited records in fixed-size chunks.
while chunk := await process.stdout.read(1024 * 1024):
count += chunk.count(b"\0")
await process.wait()
stderr = await stderr_task if stderr_task is not None else b""
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode().strip()
@@ -1574,7 +1550,7 @@ class SyncService:
count += 1
return count
return count
return int(stdout.strip())
async def _scan_directory_modified_since(
self, directory: Path, since_timestamp: float
@@ -1607,16 +1583,8 @@ class SyncService:
# Convert timestamp to find-compatible format
since_date = datetime.fromtimestamp(since_timestamp).strftime("%Y-%m-%d %H:%M:%S")
# Trigger: incremental scans ask `find` to filter by modification time.
# Why: passing argv directly avoids shell interpretation of paths and timestamps.
# Outcome: optimized scanning keeps its speed without a shell injection boundary.
process = await asyncio.create_subprocess_exec(
"find",
str(directory),
"-type",
"f",
"-newermt",
since_date,
process = await asyncio.create_subprocess_shell(
f'find "{directory}" -type f -newermt "{since_date}"',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
+33 -75
View File
@@ -93,7 +93,6 @@ class WatchService:
self.status_path = app_config.data_dir_path / WATCH_STATUS_JSON
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self._ignore_patterns_cache: dict[Path, Set[str]] = {}
self._sorted_watch_filter_roots: tuple[Path, ...] | None = None
self._sync_service_factory = sync_service_factory
# When set (typically from BASIC_MEMORY_MCP_PROJECT), the watch cycle
# only observes this project. Without it, each `basic-memory mcp --project X`
@@ -127,54 +126,41 @@ class WatchService:
async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: asyncio.Event):
"""Run one cycle of watching the given projects until stop_event is set."""
project_paths = [project.path for project in projects]
previous_filter_roots = self._sorted_watch_filter_roots
self._sorted_watch_filter_roots = tuple(
sorted(
(Path(project.path).expanduser().resolve() for project in projects),
# Trigger: configured project roots can overlap.
# Why: an enclosing project's hidden directory should still hide descendants.
# Outcome: choose the outermost matching root when checking hidden path parts.
key=lambda project_path: len(project_path.parts),
)
)
try:
async for changes in awatch(
*project_paths,
debounce=self.app_config.sync_delay,
watch_filter=self.filter_changes,
recursive=True,
stop_event=stop_event,
):
# group changes by project and filter using ignore patterns
project_changes = defaultdict(list)
for change, path in changes:
for project in projects:
if self.is_project_path(project, path):
# Check if the file should be ignored based on gitignore patterns
project_path = Path(project.path)
file_path = Path(path)
ignore_patterns = self._get_ignore_patterns(project_path)
async for changes in awatch(
*project_paths,
debounce=self.app_config.sync_delay,
watch_filter=self.filter_changes,
recursive=True,
stop_event=stop_event,
):
# group changes by project and filter using ignore patterns
project_changes = defaultdict(list)
for change, path in changes:
for project in projects:
if self.is_project_path(project, path):
# Check if the file should be ignored based on gitignore patterns
project_path = Path(project.path)
file_path = Path(path)
ignore_patterns = self._get_ignore_patterns(project_path)
if should_ignore_path(file_path, project_path, ignore_patterns):
logger.trace(
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
)
continue
if should_ignore_path(file_path, project_path, ignore_patterns):
logger.trace(
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
)
continue
project_changes[project].append((change, path))
break
project_changes[project].append((change, path))
break
# create coroutines to handle changes
change_handlers = [
self.handle_changes(project, set(changes))
for project, changes in project_changes.items()
]
# create coroutines to handle changes
change_handlers = [
self.handle_changes(project, set(changes))
for project, changes in project_changes.items()
]
# process changes
await asyncio.gather(*change_handlers)
finally:
self._sorted_watch_filter_roots = previous_filter_roots
# process changes
await asyncio.gather(*change_handlers)
async def _select_projects_to_watch(self) -> list[Project]:
"""Return the set of projects this watch cycle should observe.
@@ -281,43 +267,15 @@ class WatchService:
self.state.running = False
await self.write_status()
def filter_changes(self, change: Change, path: str) -> bool:
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
"""Filter to only watch non-hidden files and directories.
Returns:
True if the file should be watched, False if it should be ignored
"""
path_obj = Path(path).expanduser().resolve()
project_paths = self._sorted_watch_filter_roots
if project_paths is None:
project_paths = tuple(
sorted(
(
Path(entry.path).expanduser().resolve()
for entry in self.app_config.projects.values()
if entry.path
),
# Trigger: direct callers may not run inside a watch cycle.
# Why: tests and one-off calls still need the same hidden-path semantics.
# Outcome: compute the stable outermost-first order only for fallback calls.
key=lambda project_path: len(project_path.parts),
)
)
relative_path = None
for project_path in project_paths:
try:
relative_path = path_obj.relative_to(project_path)
break
except ValueError:
continue
# Trigger: a project may live under a hidden parent such as ~/.claude.
# Why: only dotfiles and dot-directories inside the watched project should be ignored.
# Outcome: hidden parents outside the project root do not mute legitimate project changes.
path_parts = relative_path.parts if relative_path is not None else path_obj.parts
# Skip hidden directories and files
path_parts = Path(path).parts
for part in path_parts:
if part.startswith("."):
return False
-131
View File
@@ -276,137 +276,6 @@ def build_canonical_permalink(
return project_path
def build_qualified_permalink_reference(
project_permalink: Optional[str],
identifier: Union[Path, str, PathLike],
include_project: bool = True,
*,
workspace_permalink: Optional[str] = None,
) -> str:
"""Add workspace/project route prefixes while preserving lookup syntax.
Unlike ``build_canonical_permalink()``, this helper does not run the identifier
through permalink generation. It is for inbound references that may contain
lookup-only syntax such as ``.md`` extensions or ``*`` glob patterns.
"""
normalized_path = normalize_project_reference(str(identifier)).strip("/")
normalized_workspace = generate_permalink(workspace_permalink) if workspace_permalink else None
normalized_project = generate_permalink(project_permalink) if project_permalink else None
if normalized_workspace:
if not normalized_project:
raise ValueError("workspace_permalink requires project_permalink")
workspace_project_prefix = f"{normalized_workspace}/{normalized_project}"
if not normalized_path:
return workspace_project_prefix
if normalized_path == workspace_project_prefix or normalized_path.startswith(
f"{workspace_project_prefix}/"
):
return normalized_path
if normalized_path == normalized_project or normalized_path.startswith(
f"{normalized_project}/"
):
return f"{normalized_workspace}/{normalized_path}"
return f"{workspace_project_prefix}/{normalized_path}"
if not include_project or not normalized_project:
return normalized_path
if not normalized_path:
return normalized_project
if normalized_path == normalized_project or normalized_path.startswith(
f"{normalized_project}/"
):
return normalized_path
return f"{normalized_project}/{normalized_path}"
def build_permalink_resolution_candidates(
identifier: Union[Path, str, PathLike],
project_permalink: Optional[str],
include_project: bool = True,
*,
workspace_permalink: Optional[str] = None,
) -> list[str]:
"""Return permalink candidates from most caller-specific to broadest legacy form.
The first candidate preserves the normalized identifier the caller supplied. Follow-up
candidates add the active workspace/project route and legacy project/path forms so
all resolver callers share the same compatibility behavior.
"""
exact_path = normalize_project_reference(str(identifier)).strip("/")
normalized_path = generate_permalink(exact_path).strip("/")
normalized_project = generate_permalink(project_permalink) if project_permalink else None
normalized_workspace = generate_permalink(workspace_permalink) if workspace_permalink else None
candidates: list[str] = []
def add_candidate(value: str | None) -> None:
if value and value not in candidates:
candidates.append(value)
add_candidate(exact_path)
add_candidate(normalized_path)
if not normalized_project:
return candidates
workspace_project_prefix = (
f"{normalized_workspace}/{normalized_project}" if normalized_workspace else None
)
workspace_qualified = False
if workspace_project_prefix:
add_candidate(
build_canonical_permalink(
normalized_project,
normalized_path,
include_project=include_project,
workspace_permalink=normalized_workspace,
)
)
if normalized_path == workspace_project_prefix:
workspace_qualified = True
add_candidate(normalized_project)
elif normalized_path.startswith(f"{workspace_project_prefix}/"):
workspace_qualified = True
remainder = normalized_path.removeprefix(f"{workspace_project_prefix}/")
add_candidate(f"{normalized_project}/{remainder}")
add_candidate(remainder)
if workspace_project_prefix and not include_project and not workspace_qualified:
# Trigger: short lookup in a workspace where new canonical links omit project prefixes.
# Why: older rows in that same workspace may still be stored as `project/path`.
# Outcome: try the project-prefixed legacy form after the workspace-qualified form.
add_candidate(
build_canonical_permalink(
normalized_project,
normalized_path,
include_project=True,
)
)
if include_project and not workspace_qualified:
add_candidate(
build_canonical_permalink(
normalized_project,
normalized_path,
include_project=True,
)
)
if normalized_path == normalized_project:
return candidates
if normalized_path.startswith(f"{normalized_project}/"):
remainder = normalized_path.removeprefix(f"{normalized_project}/")
add_candidate(remainder)
if not include_project and normalized_path.startswith(f"{normalized_project}/"):
# Trigger: caller supplied `project/path` while legacy short permalinks are stored.
# Why: routing uses the project prefix, but strict lookup still needs the short row.
# Outcome: try `path` after the exact project-qualified candidate.
add_candidate(normalized_path.removeprefix(f"{normalized_project}/"))
return candidates
def setup_logging(
log_level: str = "INFO",
log_to_file: bool = False,
+2 -2
View File
@@ -14,14 +14,14 @@ _WORKSPACE_TYPES = {"personal", "organization"}
@dataclass(frozen=True)
class WorkspacePermalinkContext:
"""Workspace metadata needed to build canonical workspace permalinks."""
"""Workspace metadata needed to build canonical organization permalinks."""
workspace_slug: str
workspace_type: str
@property
def should_prefix_permalinks(self) -> bool:
return bool(self.workspace_slug)
return self.workspace_type == "organization" and bool(self.workspace_slug)
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(
-11
View File
@@ -138,17 +138,6 @@ async def cleanup_global_db_after_test() -> AsyncGenerator[None, None]:
await db.shutdown_db()
@pytest.fixture(autouse=True)
def clean_routing_env(monkeypatch) -> None:
"""Keep CLI routing env mutations from leaking between integration tests."""
# Trigger: CLI integration tests exercise long-running MCP entrypoints that set routing env.
# Why: those commands normally own the process lifetime, but pytest keeps reusing it.
# Outcome: every integration test starts from neutral routing unless it opts in explicitly.
monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False)
monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False)
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
POSTGRES_EPHEMERAL_TABLES = [
"search_vector_embeddings",
"search_vector_chunks",
@@ -1,30 +1,34 @@
"""Integration test for long prose before inline wikilinks.
"""
Integration test for long relation_type values (regression guard for issue #721).
Issue #721 was originally triggered by markdown bullets that contained inline
`[[wikilinks]]` preceded by long prose. The parser treated all prose before the
wikilink as `relation_type`, and the response model's former `MaxLen(200)`
constraint caused edit_note to fail with:
When a markdown bullet contains an inline `[[wikilink]]` preceded by long prose,
`parse_relation()` extracts ALL of that prose as the `relation_type`. Previously
the response model `RelationType` had a `MaxLen(200)` constraint that caused
edit_note (which round-trips through the response model when re-indexing) to
fail with:
1 validation error for EntityResponseV2
relations.0.relation_type
String should have at most 200 characters
The relation grammar now fixes the root ambiguity too: unquoted multi-word
prefixes are prose, so this shape should index as a generic `links_to`
relation rather than preserving prose as a custom relation type.
Commit 01cbad1d removed the cap from `RelationType`. This test locks in that
fix so a future contributor reintroducing `MaxLen` will see the test fail
before shipping it.
Out of scope: improving `parse_relation()` to fall back to a default relation
type when the prose-before-link looks like a sentence rather than a label.
That's a knowledge-graph-quality improvement, not a correctness fix, and is
not required to keep edit_note working.
"""
import pytest
from fastmcp import Client
from basic_memory.repository.relation_repository import RelationRepository
@pytest.mark.asyncio
async def test_edit_note_handles_long_prose_around_wikilink(
mcp_server, app, test_project, engine_factory
):
"""Long prose before an inline wikilink should not become a relation type."""
async def test_edit_note_handles_long_prose_around_wikilink(mcp_server, app, test_project):
"""edit_note must succeed on notes whose inline wikilinks have >200 chars
of prose preceding them that prose becomes the parsed relation_type."""
long_prose = (
"**Lorem ipsum dolor sit amet** — consectetur adipiscing elit, sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, "
@@ -71,11 +75,3 @@ async def test_edit_note_handles_long_prose_around_wikilink(
)
assert len(edit_result.content) == 1
assert "Edited note (append)" in edit_result.content[0].text
_, session_maker = engine_factory
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
links_to_relations = await relation_repository.find_by_type("links_to")
prose_type_relations = await relation_repository.find_by_type(long_prose)
assert any(relation.to_name == "Some Note Title" for relation in links_to_relations)
assert not prose_type_relations
@@ -28,7 +28,6 @@ async def test_search_notes_entity_types_as_string(mcp_server, app, test_project
{
"project": test_project.name,
"query": "coercion",
"search_type": "text",
"entity_types": '["entity"]',
},
)
@@ -55,7 +54,6 @@ async def test_search_notes_note_types_as_string(mcp_server, app, test_project):
{
"project": test_project.name,
"query": "coercion",
"search_type": "text",
"note_types": '["note"]',
},
)
@@ -83,7 +81,6 @@ async def test_search_notes_tags_as_string(mcp_server, app, test_project):
{
"project": test_project.name,
"query": "tagged",
"search_type": "text",
"tags": '["alpha"]',
},
)
@@ -110,7 +107,6 @@ async def test_search_notes_metadata_filters_as_string(mcp_server, app, test_pro
{
"project": test_project.name,
"query": "metadata",
"search_type": "text",
"metadata_filters": '{"type": "note"}',
},
)
@@ -1,463 +0,0 @@
"""Integration coverage for local, cloud, and team permalink routing."""
from __future__ import annotations
import json
from collections.abc import Iterable
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from typing import Any
import pytest
import pytest_asyncio
from fastmcp import Client
from httpx import ASGITransport, AsyncClient as HttpxAsyncClient
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry
from basic_memory.mcp import async_client
from basic_memory.mcp import project_context
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.workspace_context import workspace_permalink_headers
def _json_content(tool_result) -> Any:
"""Parse a FastMCP tool result content block into JSON."""
assert len(tool_result.content) == 1
assert tool_result.content[0].type == "text"
return json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
def _workspace(
*,
tenant_id: str,
slug: str,
workspace_type: str,
is_default: bool = True,
) -> WorkspaceInfo:
return WorkspaceInfo(
tenant_id=tenant_id,
name=slug.replace("-", " ").title(),
workspace_type=workspace_type,
slug=slug,
role="owner",
organization_id=None,
is_default=is_default,
has_active_subscription=True,
)
@pytest.fixture
def personal_workspace() -> WorkspaceInfo:
return _workspace(
tenant_id="personal-tenant",
slug="personal",
workspace_type="personal",
)
@pytest.fixture
def team_workspace() -> WorkspaceInfo:
return _workspace(
tenant_id="team-tenant",
slug="team-paul",
workspace_type="organization",
is_default=False,
)
@pytest.fixture
def route_workspaces(app):
@contextmanager
def route(*workspaces: WorkspaceInfo):
with _workspace_routing(app, workspaces):
yield
return route
@pytest_asyncio.fixture
async def alternate_project(
config_home,
engine_factory,
app_config: BasicMemoryConfig,
config_manager: ConfigManager,
) -> Project:
"""Create a second project so no-project lookups cannot pass via the default."""
alternate_path = Path(config_home) / "alternate-project"
alternate_path.mkdir(parents=True, exist_ok=True)
_, session_maker = engine_factory
project_repository = ProjectRepository(session_maker)
project = await project_repository.create(
{
"name": "alternate-project",
"description": "Non-default project for prefix routing tests",
"path": str(alternate_path),
"is_active": True,
"is_default": False,
}
)
app_config.projects[project.name] = ProjectEntry(path=str(alternate_path))
config_manager.save_config(app_config)
return project
def _save_permalink_config(
app_config: BasicMemoryConfig,
*,
include_project: bool,
default_project: str | None,
) -> None:
app_config.permalinks_include_project = include_project
app_config.default_project = default_project
ConfigManager().save_config(app_config)
@contextmanager
def _workspace_routing(app, workspaces: Iterable[WorkspaceInfo]):
"""Route MCP tool HTTP calls through an ASGI-backed cloud workspace seam."""
workspace_list = tuple(workspaces)
workspace_ids = {workspace.tenant_id for workspace in workspace_list}
async def workspace_provider():
return list(workspace_list)
@asynccontextmanager
async def factory(workspace: str | None = None):
assert workspace is None or workspace in workspace_ids
async with HttpxAsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers=workspace_permalink_headers(),
) as inner:
yield inner
original_factory = async_client._client_factory
original_workspace_provider = project_context._workspace_provider
async_client.set_client_factory(factory)
project_context.set_workspace_provider(workspace_provider)
try:
yield
finally:
async_client._client_factory = original_factory
project_context._workspace_provider = original_workspace_provider
async def _call_json(mcp_server, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
async with Client(mcp_server) as client:
result = await client.call_tool(tool_name, arguments)
payload = _json_content(result)
assert isinstance(payload, dict)
return payload
async def _write_json(
mcp_server,
*,
project: str,
title: str,
directory: str = "permalink-suite",
) -> dict[str, Any]:
return await _call_json(
mcp_server,
"write_note",
{
"project": project,
"title": title,
"directory": directory,
"content": f"# {title}\n\nUnique body for {title}.",
"output_format": "json",
},
)
async def _read_json(
mcp_server,
*,
identifier: str,
project: str | None = None,
) -> dict[str, Any]:
arguments = {
"identifier": identifier,
"output_format": "json",
}
if project is not None:
arguments["project"] = project
return await _call_json(mcp_server, "read_note", arguments)
async def _search_single_permalink(
mcp_server,
*,
title: str,
project: str | None = None,
) -> str:
arguments = {
"query": title,
"search_type": "text",
"output_format": "json",
}
if project is not None:
arguments["project"] = project
payload = await _call_json(mcp_server, "search_notes", arguments)
matching_results = [
item for item in payload["results"] if isinstance(item, dict) and item.get("title") == title
]
assert len(matching_results) == 1
permalink = matching_results[0].get("permalink")
assert isinstance(permalink, str)
return permalink
async def _search_permalink_exact(
mcp_server,
*,
permalink: str,
title: str,
project: str | None = None,
) -> str:
arguments = {
"query": permalink,
"search_type": "permalink",
"output_format": "json",
}
if project is not None:
arguments["project"] = project
payload = await _call_json(mcp_server, "search_notes", arguments)
matching_results = [
item for item in payload["results"] if isinstance(item, dict) and item.get("title") == title
]
assert len(matching_results) == 1
result_permalink = matching_results[0].get("permalink")
assert isinstance(result_permalink, str)
return result_permalink
@pytest.mark.asyncio
async def test_local_short_permalink_round_trips_when_project_is_supplied(
mcp_server,
test_project,
app_config,
):
"""Local short IDs need the project argument for write/search/read routing."""
_save_permalink_config(app_config, include_project=False, default_project=None)
title = "Local Short Permalink"
expected_permalink = "permalink-suite/local-short-permalink"
write_payload = await _write_json(mcp_server, project=test_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
assert (
await _search_single_permalink(mcp_server, project=test_project.name, title=title)
== expected_permalink
)
short_read = await _read_json(
mcp_server,
project=test_project.name,
identifier=expected_permalink,
)
assert short_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_local_project_permalink_routes_without_project_argument(
mcp_server,
alternate_project,
app_config,
):
"""Local project-qualified IDs carry enough route context for search/read."""
_save_permalink_config(app_config, include_project=True, default_project=None)
title = "Local Project Permalink"
short_permalink = "permalink-suite/local-project-permalink"
expected_permalink = f"{alternate_project.name}/{short_permalink}"
write_payload = await _write_json(mcp_server, project=alternate_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
assert (
await _search_permalink_exact(
mcp_server,
permalink=expected_permalink,
title=title,
)
== expected_permalink
)
project_read = await _read_json(mcp_server, identifier=expected_permalink)
assert project_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_personal_cloud_short_permalink_round_trips_when_project_is_supplied(
mcp_server,
test_project,
app_config,
personal_workspace,
route_workspaces,
):
"""Legacy short IDs remain readable/searchable in a personal cloud workspace."""
_save_permalink_config(app_config, include_project=False, default_project=None)
title = "Personal Cloud Short Permalink"
expected_permalink = "permalink-suite/personal-cloud-short-permalink"
write_payload = await _write_json(mcp_server, project=test_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
with route_workspaces(personal_workspace):
assert (
await _search_single_permalink(
mcp_server,
project=test_project.name,
title=title,
)
== expected_permalink
)
short_read = await _read_json(
mcp_server,
project=test_project.name,
identifier=expected_permalink,
)
assert short_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_personal_cloud_project_permalink_routes_without_project_argument(
mcp_server,
alternate_project,
app_config,
personal_workspace,
route_workspaces,
):
"""Legacy project-qualified cloud IDs carry enough route context."""
_save_permalink_config(app_config, include_project=True, default_project=None)
title = "Personal Cloud Project Permalink"
short_permalink = "permalink-suite/personal-cloud-project-permalink"
expected_permalink = f"{alternate_project.name}/{short_permalink}"
write_payload = await _write_json(mcp_server, project=alternate_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
with route_workspaces(personal_workspace):
assert (
await _search_permalink_exact(
mcp_server,
permalink=expected_permalink,
title=title,
)
== expected_permalink
)
project_read = await _read_json(mcp_server, identifier=expected_permalink)
assert project_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_team_short_permalink_round_trips_when_project_is_supplied(
mcp_server,
test_project,
app_config,
team_workspace,
route_workspaces,
):
"""Team short IDs need the qualified project argument for search/read routing."""
team_project = f"{team_workspace.slug}/{test_project.name}"
_save_permalink_config(app_config, include_project=False, default_project=None)
title = "Team Short Permalink"
expected_permalink = "permalink-suite/team-short-permalink"
write_payload = await _write_json(mcp_server, project=test_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
with route_workspaces(team_workspace):
assert (
await _search_single_permalink(
mcp_server,
project=team_project,
title=title,
)
== expected_permalink
)
short_read = await _read_json(
mcp_server,
project=team_project,
identifier=expected_permalink,
)
assert short_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_team_project_permalink_routes_without_project_argument(
mcp_server,
alternate_project,
app_config,
team_workspace,
route_workspaces,
):
"""Team project-qualified IDs carry enough route context for search/read."""
_save_permalink_config(app_config, include_project=True, default_project=None)
title = "Team Project Permalink"
short_permalink = "permalink-suite/team-project-permalink"
expected_permalink = f"{alternate_project.name}/{short_permalink}"
write_payload = await _write_json(mcp_server, project=alternate_project.name, title=title)
assert write_payload["permalink"] == expected_permalink
with route_workspaces(team_workspace):
assert (
await _search_permalink_exact(
mcp_server,
permalink=expected_permalink,
title=title,
)
== expected_permalink
)
project_read = await _read_json(mcp_server, identifier=expected_permalink)
assert project_read["permalink"] == expected_permalink
@pytest.mark.asyncio
async def test_team_workspace_permalink_routes_to_specific_workspace(
mcp_server,
test_project,
app_config,
personal_workspace,
team_workspace,
route_workspaces,
):
"""Workspace-qualified IDs route to the project in that workspace."""
_save_permalink_config(app_config, include_project=True, default_project=None)
team_project = f"{team_workspace.slug}/{test_project.name}"
title = "Team Workspace Permalink"
short_permalink = "permalink-suite/team-workspace-permalink"
expected_permalink = f"{team_workspace.slug}/{test_project.name}/{short_permalink}"
with route_workspaces(personal_workspace, team_workspace):
write_payload = await _write_json(mcp_server, project=team_project, title=title)
assert write_payload["permalink"] == expected_permalink
assert (
await _search_permalink_exact(
mcp_server,
permalink=expected_permalink,
title=title,
)
== expected_permalink
)
workspace_read = await _read_json(mcp_server, identifier=expected_permalink)
assert workspace_read["permalink"] == expected_permalink
+6 -12
View File
@@ -10,8 +10,6 @@ pytest
# Run tests against Postgres only (requires docker-compose)
docker-compose -f docker-compose-postgres.yml up -d
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest -m postgres
# Run tests against BOTH backends
@@ -56,7 +54,7 @@ database_url = None # Uses default SQLite path
# Postgres config
database_backend = DatabaseBackend.POSTGRES
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory"
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
```
## Running Postgres Tests
@@ -68,22 +66,18 @@ docker-compose -f docker-compose-postgres.yml up -d
```
This starts:
- Postgres 17 with **pgvector** (`pgvector/pgvector:pg17`) on port **5433** (not 5432 to avoid conflicts)
- Database: `basic_memory`
- Postgres 17 on port **5433** (not 5432 to avoid conflicts)
- Test database: `basic_memory_test`
- Credentials: `basic_memory_user` / `dev_password`
### 2. Run Postgres Tests
```bash
# Run only Postgres tests
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest -m postgres
# Run specific test with Postgres
BASIC_MEMORY_TEST_POSTGRES=1 \
POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory \
pytest tests/repository/test_entity_repository.py::test_create -m postgres
pytest tests/test_entity_repository.py::test_create -m postgres
# Skip Postgres tests (default behavior)
pytest -m "not postgres"
@@ -127,7 +121,7 @@ jobs:
# Postgres service container
services:
postgres:
image: pgvector/pgvector:pg17
image: postgres:17
env:
POSTGRES_DB: basic_memory_test
POSTGRES_USER: basic_memory_user
@@ -175,4 +169,4 @@ docker-compose -f docker-compose-postgres.yml exec postgres pg_isready -U basic_
- [ ] Add `--run-all-backends` CLI flag to run both backends in sequence
- [ ] Implement test fixtures for backend-specific features (e.g., Postgres full-text search vs SQLite FTS5)
- [ ] Add performance comparison benchmarks between backends
- [ ] Add performance comparison benchmarks between backends
-17
View File
@@ -482,23 +482,6 @@ async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
@pytest.mark.asyncio
async def test_import_rejects_oversized_file(
client: AsyncClient, tmp_path, app_config, v2_project_url: str
):
"""Import endpoints should reject files before parsing unbounded JSON."""
app_config.import_upload_max_bytes = 8
file_path = tmp_path / "large.json"
file_path.write_text(json.dumps([{"message": "too large"}]), encoding="utf-8")
with open(file_path, "rb") as f:
files = {"file": ("large.json", f, "application/json")}
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files)
assert response.status_code == 413
assert "maximum size" in response.json()["detail"]
@pytest.mark.asyncio
async def test_import_empty_file(client: AsyncClient, tmp_path, v2_project_url: str):
"""Test importing an empty file via v2 endpoint."""
+1 -1
View File
@@ -302,7 +302,7 @@ async def test_create_entity_with_observations_and_relations(
## Observations
- [note] This is a test observation #tag1 (context)
- "related to" [[OtherEntity]]
- related to [[OtherEntity]]
""",
}
-79
View File
@@ -55,25 +55,6 @@ class SpyEntityRepository:
self.calls.append(ids)
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
async def find_by_ids_for_hydration(self, ids: list[int]):
self.calls.append(ids)
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
class LightweightOnlyEntityRepository:
"""Raises if graph hydration uses the eager-loading repository method."""
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
self.entities_by_id = entities_by_id
self.hydration_calls: list[list[int]] = []
async def find_by_ids(self, ids: list[int]):
raise AssertionError("graph hydration must use the lightweight hydration lookup")
async def find_by_ids_for_hydration(self, ids: list[int]):
self.hydration_calls.append(ids)
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
# --- Single batch fetch (N+1 elimination) ---
@@ -217,63 +198,3 @@ async def test_to_graph_context_empty_results_skip_entity_lookup():
assert repo.calls == []
assert list(graph.results) == []
@pytest.mark.asyncio
async def test_to_graph_context_uses_lightweight_hydration_lookup():
"""Hydration should not load observations/relations when only entity fields are needed."""
repo = LightweightOnlyEntityRepository(
{
1: _make_entity(1, "Root", "ext-root"),
2: _make_entity(2, "Child", "ext-child"),
}
)
now = datetime.now(timezone.utc)
context = ServiceContextResult(
results=[
ContextResultItem(
primary_result=_make_row(
type="entity",
id=1,
root_id=1,
title="Root",
permalink="notes/root",
file_path="notes/root.md",
created_at=now,
),
observations=[],
related_results=[
_make_row(
type="relation",
id=20,
root_id=1,
title="links_to: Child",
permalink="notes/root",
file_path="notes/root.md",
relation_type="links_to",
from_id=1,
to_id=2,
depth=1,
created_at=now,
)
],
)
],
metadata=ContextMetadata(
types=[SearchItemType.ENTITY, SearchItemType.RELATION],
depth=1,
primary_count=1,
related_count=1,
total_relations=1,
),
)
graph = await to_graph_context(context, entity_repository=repo)
assert len(repo.hydration_calls) == 1
assert set(repo.hydration_calls[0]) == {1, 2}
relation = graph.results[0].related_results[0]
assert isinstance(relation, RelationSummary)
assert relation.from_entity_external_id == "ext-root"
assert relation.to_entity_external_id == "ext-child"
-92
View File
@@ -1,92 +0,0 @@
"""Tests for the /knowledge/orphans API endpoint."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_get_orphan_entities_empty_project(client: AsyncClient, v2_project_url):
"""An empty project returns an empty orphans list."""
response = await client.get(f"{v2_project_url}/knowledge/orphans")
assert response.status_code == 200
assert response.json() == {"entities": [], "total": 0}
@pytest.mark.asyncio
async def test_get_orphan_entities_returns_unlinked_entities(client: AsyncClient, v2_project_url):
"""Entities with no relations appear in the orphans endpoint."""
first = await client.post(
f"{v2_project_url}/knowledge/entities",
json={"title": "Orphan One", "directory": "orphan", "content": "No links here"},
)
second = await client.post(
f"{v2_project_url}/knowledge/entities",
json={"title": "Orphan Two", "directory": "orphan", "content": "Also no links"},
)
assert first.status_code == 200
assert second.status_code == 200
response = await client.get(f"{v2_project_url}/knowledge/orphans")
assert response.status_code == 200
data = response.json()
titles = {entity["title"] for entity in data["entities"]}
assert titles == {"Orphan One", "Orphan Two"}
assert data["total"] == 2
@pytest.mark.asyncio
async def test_get_orphan_entities_excludes_incoming_and_outgoing_relation_nodes(
client: AsyncClient, v2_project_url
):
"""Entities with either side of a resolved relation are excluded from orphans."""
target = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": "Target Note",
"directory": "linked",
"content": "Referenced entity",
},
)
source = await client.post(
f"{v2_project_url}/knowledge/entities",
json={
"title": "Source Note",
"directory": "linked",
"content": "- links_to [[Target Note]]",
},
)
standalone = await client.post(
f"{v2_project_url}/knowledge/entities",
json={"title": "Standalone Note", "directory": "linked", "content": "No links"},
)
assert source.status_code == 200
assert target.status_code == 200
assert standalone.status_code == 200
response = await client.get(f"{v2_project_url}/knowledge/orphans")
assert response.status_code == 200
titles = {entity["title"] for entity in response.json()["entities"]}
assert "Source Note" not in titles
assert "Target Note" not in titles
assert "Standalone Note" in titles
@pytest.mark.asyncio
async def test_get_orphan_entities_response_shape(client: AsyncClient, v2_project_url):
"""Each orphan entity in the response has the expected graph-node fields."""
created = await client.post(
f"{v2_project_url}/knowledge/entities",
json={"title": "Shape Test", "directory": "shape", "content": "Testing shape"},
)
assert created.status_code == 200
response = await client.get(f"{v2_project_url}/knowledge/orphans")
assert response.status_code == 200
data = response.json()
entity = next(entity for entity in data["entities"] if entity["title"] == "Shape Test")
assert set(entity) == {"external_id", "title", "note_type", "file_path"}
assert entity["file_path"].endswith(".md")
-15
View File
@@ -321,21 +321,6 @@ async def test_resolve_project_by_permalink(
assert resolved.resolution_method in ["name", "permalink"]
@pytest.mark.asyncio
async def test_resolve_project_by_workspace_qualified_permalink(
client: AsyncClient, test_project: Project, v2_projects_url
):
"""Resolve the workspace/project form shown by MCP disambiguation errors."""
resolve_data = {"identifier": f"personal/{test_project.name}"}
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
assert response.status_code == 200
resolved = ProjectResolveResponse.model_validate(response.json())
assert resolved.external_id == test_project.external_id
assert resolved.name == test_project.name
assert resolved.resolution_method == "permalink"
@pytest.mark.asyncio
async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
"""Test resolving a project by external_id string returns correct project external_id."""
+37
View File
@@ -155,6 +155,43 @@ async def test_search_with_item_type_filter_returns_total(
assert len(data["results"]) == 3
@pytest.mark.asyncio
async def test_search_accepts_typed_facet_filters(
client: AsyncClient,
app,
v2_project_url: str,
):
"""The v2 API contract should preserve observation and relation typed filters."""
captured_queries = []
class CapturingSearchService:
async def search(self, query, *, limit, offset):
captured_queries.append(("search", query, limit, offset))
return []
async def count(self, query):
captured_queries.append(("count", query))
return 0
app.dependency_overrides[get_search_service_v2_external] = lambda: CapturingSearchService()
try:
response = await client.post(
f"{v2_project_url}/search/",
json={
"entity_types": ["observation"],
"observation_categories": ["appearance"],
"relation_types": ["associated_with"],
},
)
finally:
app.dependency_overrides.pop(get_search_service_v2_external, None)
assert response.status_code == 200
_, query, _, _ = captured_queries[0]
assert query.observation_categories == ["appearance"]
assert query.relation_types == ["associated_with"]
@pytest.mark.asyncio
async def test_search_by_permalink(
client: AsyncClient,
@@ -32,48 +32,13 @@ def test_convert_bmignore_to_rclone_filters_creates_and_converts(config_home):
# Comments/empties preserved
assert "# comment" in content
assert "" in content
# Plain and wildcard patterns exclude direct matches and recursive contents.
assert "- node_modules" in content
# Directory pattern becomes recursive exclude
assert "- node_modules/**" in content
# Wildcard pattern becomes simple exclude
assert "- *.pyc" in content
assert "- *.pyc/**" in content
assert "- .git" in content
assert "- .git/**" in content
def test_convert_bmignore_to_rclone_filters_excludes_files_and_hidden_directory_contents(
config_home,
):
bmignore = get_bmignore_path()
bmignore.parent.mkdir(parents=True, exist_ok=True)
bmignore.write_text("config.json\n.*\nnode_modules/**\n", encoding="utf-8")
rclone_filter = convert_bmignore_to_rclone_filters()
content = rclone_filter.read_text(encoding="utf-8").splitlines()
assert "- config.json" in content
assert "- config.json/**" in content
assert "- .*" in content
assert "- .*/**" in content
assert "- node_modules" in content
assert "- node_modules/**" in content
def test_convert_bmignore_to_rclone_filters_preserves_directory_only_patterns(config_home):
bmignore = get_bmignore_path()
bmignore.parent.mkdir(parents=True, exist_ok=True)
bmignore.write_text("cache/\nconfig.json/**\n", encoding="utf-8")
rclone_filter = convert_bmignore_to_rclone_filters()
content = rclone_filter.read_text(encoding="utf-8").splitlines()
assert "- cache/" in content
assert "- cache/**" in content
assert "- cache" not in content
assert "- config.json" in content
assert "- config.json/**" in content
def test_convert_bmignore_to_rclone_filters_is_cached_when_up_to_date(config_home):
bmignore = get_bmignore_path()
bmignore.parent.mkdir(parents=True, exist_ok=True)
-54
View File
@@ -1,54 +0,0 @@
"""Tests for secure rclone installer fallbacks."""
import pytest
from basic_memory.cli.commands.cloud import rclone_installer
def test_macos_installer_does_not_fallback_to_remote_script(monkeypatch):
"""Homebrew failure should produce manual guidance, not curl-piped sudo bash."""
commands: list[list[str]] = []
def fake_which(command: str) -> str | None:
return "/opt/homebrew/bin/brew" if command == "brew" else None
def fake_run(command: list[str], check: bool = True):
commands.append(command)
raise rclone_installer.RcloneInstallError("brew failed")
monkeypatch.setattr(rclone_installer.shutil, "which", fake_which)
monkeypatch.setattr(rclone_installer, "run_command", fake_run)
with pytest.raises(rclone_installer.RcloneInstallError) as exc_info:
rclone_installer.install_rclone_macos()
assert commands == [["brew", "install", "rclone"]]
assert "curl" not in str(exc_info.value)
assert "sudo bash" not in str(exc_info.value)
assert "brew install rclone" in str(exc_info.value)
def test_linux_installer_uses_package_managers_only(monkeypatch):
"""Linux package-manager failures should not fall through to remote script execution."""
commands: list[list[str]] = []
def fake_which(command: str) -> str | None:
return f"/usr/bin/{command}" if command in {"apt", "snap"} else None
def fake_run(command: list[str], check: bool = True):
commands.append(command)
raise rclone_installer.RcloneInstallError("install failed")
monkeypatch.setattr(rclone_installer.shutil, "which", fake_which)
monkeypatch.setattr(rclone_installer, "run_command", fake_run)
with pytest.raises(rclone_installer.RcloneInstallError) as exc_info:
rclone_installer.install_rclone_linux()
assert commands == [
["sudo", "apt", "update"],
["sudo", "snap", "install", "rclone"],
]
assert all("curl" not in token for command in commands for token in command)
assert "sudo bash" not in str(exc_info.value)
assert "sudo apt install rclone" in str(exc_info.value)
-23
View File
@@ -80,26 +80,3 @@ def test_bm_version_does_not_import_heavy_modules():
assert "CLEAN" in result.stdout, (
f"Heavy modules loaded during --version: {result.stdout.strip()}"
)
def test_bm_help_does_not_import_api_app():
"""Regression test: 'bm --help' must not build the FastAPI app graph."""
check_script = (
"import sys; "
"sys.argv = ['bm', '--help']; "
"import basic_memory.cli.main; "
"heavy = [m for m in sys.modules "
"if m == 'basic_memory.api.app' or m.startswith('basic_memory.api.v2.routers')]; "
"print(','.join(heavy) if heavy else 'CLEAN')"
)
result = subprocess.run(
["uv", "run", "python", "-c", check_script],
capture_output=True,
text=True,
timeout=10,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "CLEAN" in result.stdout, (
f"API app modules loaded during --help: {result.stdout.strip()}"
)
-150
View File
@@ -1,150 +0,0 @@
"""Tests for the 'basic-memory orphans' CLI command."""
import json
from contextlib import asynccontextmanager, nullcontext
from unittest.mock import AsyncMock, MagicMock, patch
from mcp.server.fastmcp.exceptions import ToolError
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
from basic_memory.schemas.v2.graph import GraphNode
import basic_memory.cli.commands.orphans as orphans_cmd # noqa: F401
runner = CliRunner()
_MOCK_PROJECT_ITEM = MagicMock()
_MOCK_PROJECT_ITEM.name = "test-project"
_MOCK_PROJECT_ITEM.external_id = "11111111-1111-1111-1111-111111111111"
_ORPHAN_ENTITIES = [
GraphNode(
external_id="aaaa-1111",
title="Isolated Note",
file_path="notes/isolated.md",
note_type="note",
),
GraphNode(
external_id="bbbb-2222",
title="Dangling Spec",
file_path="specs/dangling.md",
note_type="spec",
),
]
def _mock_config_manager():
mock_config = MagicMock()
mock_config.default_project = "test-project"
return mock_config
@asynccontextmanager
async def _fake_get_client(project_name=None):
yield MagicMock()
@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.force_routing")
def test_orphans_preserves_project_routing_by_default(mock_force_routing, mock_run_orphans):
"""Default invocation keeps routing implicit so project mode can choose local/cloud."""
mock_force_routing.return_value = nullcontext()
mock_run_orphans.return_value = ("test-project", [])
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_force_routing.assert_called_once_with(local=False, cloud=False)
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_json_output(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls):
"""basic-memory orphans --json outputs a JSON array of orphan entity objects."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge = AsyncMock()
mock_knowledge.get_orphans.return_value = _ORPHAN_ENTITIES
mock_knowledge_cls.return_value = mock_knowledge
result = runner.invoke(cli_app, ["orphans", "--json"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
json_start = result.output.rfind("[\n")
data = json.loads(result.output[json_start:])
titles = {entity["title"] for entity in data}
assert titles == {"Isolated Note", "Dangling Spec"}
mock_get_client.assert_called_once_with(project_name="test-project")
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_table_output(
mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls
):
"""basic-memory orphans renders a table with orphan titles and paths."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge = AsyncMock()
mock_knowledge.get_orphans.return_value = _ORPHAN_ENTITIES
mock_knowledge_cls.return_value = mock_knowledge
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "Isolated Note" in result.output
assert "Dangling Spec" in result.output
assert "notes/isolated.md" in result.output
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_no_results(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls):
"""basic-memory orphans prints a success message when no orphans are found."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge = AsyncMock()
mock_knowledge.get_orphans.return_value = []
mock_knowledge_cls.return_value = mock_knowledge
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "No orphan entities" in result.output
@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.force_routing")
def test_orphans_value_error(mock_force_routing, mock_run_orphans):
"""User-facing command errors are printed and exit with failure."""
mock_force_routing.return_value = nullcontext()
mock_run_orphans.side_effect = ValueError("project not found")
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 1
assert "Error: project not found" in result.output
@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.force_routing")
def test_orphans_tool_error_json_output(mock_force_routing, mock_run_orphans):
"""User-facing command errors are JSON formatted when requested."""
mock_force_routing.return_value = nullcontext()
mock_run_orphans.side_effect = ToolError("cloud request failed")
result = runner.invoke(cli_app, ["orphans", "--json"])
assert result.exit_code == 1
json_start = result.output.rfind("{\n")
assert json.loads(result.output[json_start:]) == {"error": "cloud request failed"}
-695
View File
@@ -10,7 +10,6 @@ from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.mcp.clients.project import ProjectClient
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectList
# Importing registers project subcommands on the shared app instance.
@@ -54,26 +53,6 @@ def mock_client(monkeypatch):
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
def _workspace(
*,
tenant_id: str,
slug: str,
name: str,
workspace_type: str,
is_default: bool = False,
) -> WorkspaceInfo:
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug,
name=name,
role="owner",
is_default=is_default,
organization_id=None,
has_active_subscription=True,
)
def test_project_list_shows_local_cloud_presence_and_routes(
runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch
):
@@ -236,680 +215,6 @@ def test_project_list_shows_display_name_for_private_projects(
assert private_project["display_name"] == "My Project"
def test_project_list_cloud_fetches_all_workspaces_and_labels_duplicate_permalinks(
runner: CliRunner, write_config, monkeypatch
):
"""Cloud project list should include every workspace without collapsing matching names."""
write_config(
{
"env": "dev",
"projects": {},
"default_project": None,
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
team = _workspace(
tenant_id="tenant-team",
slug="team",
name="Team",
workspace_type="organization",
)
async def fake_get_available_workspaces():
return [personal, team]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
payloads_by_workspace = {
None: {"projects": [], "default_project": None},
"tenant-personal": {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "shared",
"path": "/personal/shared",
"is_default": True,
}
],
"default_project": "shared",
},
"tenant-team": {
"projects": [
{
"id": 2,
"external_id": "22222222-2222-2222-2222-222222222222",
"name": "shared",
"path": "/team/shared",
"is_default": False,
}
],
"default_project": None,
},
}
seen_workspaces: list[str | None] = []
async def fake_list_projects(self):
workspace = self.http_client.workspace
seen_workspaces.append(workspace)
return ProjectList.model_validate(payloads_by_workspace[workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(app, ["project", "list", "--json"], env={"COLUMNS": "240"})
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
# The initial None call is the local project fetch before cloud workspaces are listed.
assert seen_workspaces == [None, "tenant-personal", "tenant-team"]
data = json.loads(result.stdout)
shared_projects = [project for project in data["projects"] if project["name"] == "shared"]
assert len(shared_projects) == 2
assert {project["cloud_path"] for project in shared_projects} == {
"/personal/shared",
"/team/shared",
}
assert {project["workspace"] for project in shared_projects} == {"Personal", "Team"}
assert {project["workspace_type"] for project in shared_projects} == {
"personal",
"organization",
}
table_result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"})
assert table_result.exit_code == 0
assert "Personal (personal)" in table_result.stdout
assert "Team (organization)" in table_result.stdout
def test_project_list_workspace_discovery_failure_warns_and_uses_fallback(
runner: CliRunner, write_config, monkeypatch
):
"""Workspace discovery failures should fall back and explain the degraded result."""
write_config(
{
"env": "dev",
"projects": {},
"default_project": None,
"default_workspace": "tenant-default",
"cloud_api_key": "bmc_test_key_123",
}
)
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
async def fail_get_available_workspaces():
raise RuntimeError("workspace service unavailable")
payloads_by_workspace = {
None: {"projects": [], "default_project": None},
"tenant-default": {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "fallback-project",
"path": "/fallback-project",
"is_default": False,
}
],
"default_project": None,
},
}
async def fake_list_projects(self):
return ProjectList.model_validate(payloads_by_workspace[self.http_client.workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fail_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"})
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
assert "fallback-project" in result.stdout
assert "Cloud workspace discovery failed: workspace service unavailable" in result.stdout
assert "Showing cloud projects from the configured/default workspace only" in result.stdout
def test_project_list_partial_workspace_failure_warns_and_keeps_successes(
runner: CliRunner, write_config, monkeypatch
):
"""A failed workspace fetch should not hide projects from successful workspaces."""
write_config(
{
"env": "dev",
"projects": {},
"default_project": None,
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
team = _workspace(
tenant_id="tenant-team",
slug="team",
name="Team",
workspace_type="organization",
)
async def fake_get_available_workspaces():
return [personal, team]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
payloads_by_workspace = {
None: {"projects": [], "default_project": None},
"tenant-personal": {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "personal-project",
"path": "/personal-project",
"is_default": False,
}
],
"default_project": None,
},
}
async def fake_list_projects(self):
if self.http_client.workspace == "tenant-team":
raise RuntimeError("team unavailable")
return ProjectList.model_validate(payloads_by_workspace[self.http_client.workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"})
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
assert "personal-project" in result.stdout
assert "Cloud project discovery failed for workspace Team: team unavailable" in result.stdout
def test_project_list_workspace_type_filter_selects_unique_workspace(
runner: CliRunner, write_config, monkeypatch
):
"""--workspace can use a workspace type when it resolves to one workspace."""
write_config(
{
"env": "dev",
"projects": {},
"default_project": None,
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
team = _workspace(
tenant_id="tenant-team",
slug="team",
name="Team",
workspace_type="organization",
)
async def fake_get_available_workspaces():
return [personal, team]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
payloads_by_workspace = {
None: {"projects": [], "default_project": None},
"tenant-team": {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "team-project",
"path": "/team-project",
"is_default": True,
}
],
"default_project": "team-project",
},
}
seen_workspaces: list[str | None] = []
async def fake_list_projects(self):
workspace = self.http_client.workspace
seen_workspaces.append(workspace)
return ProjectList.model_validate(payloads_by_workspace[workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(
app,
["project", "list", "--workspace", "organization", "--json"],
env={"COLUMNS": "240"},
)
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
assert seen_workspaces == [None, "tenant-team"]
data = json.loads(result.stdout)
assert [project["name"] for project in data["projects"]] == ["team-project"]
assert data["projects"][0]["workspace"] == "Team"
def test_project_list_workspace_type_filter_lists_ambiguous_matches(
runner: CliRunner, write_config, monkeypatch
):
"""Ambiguous workspace type filters should list copyable matching slugs."""
write_config(
{
"env": "dev",
"projects": {},
"default_project": None,
"cloud_api_key": "bmc_test_key_123",
}
)
async def fake_get_available_workspaces():
return [
_workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
),
_workspace(
tenant_id="tenant-team-alpha",
slug="team-alpha",
name="Team Alpha",
workspace_type="organization",
),
_workspace(
tenant_id="tenant-team-beta",
slug="team-beta",
name="Team Beta",
workspace_type="organization",
),
]
@asynccontextmanager
async def fake_get_client(workspace=None):
yield object()
async def fake_list_projects(self):
return ProjectList.model_validate({"projects": [], "default_project": None})
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(
app,
["project", "list", "--workspace", "organization"],
env={"COLUMNS": "240"},
)
assert result.exit_code == 1
assert "Workspace 'organization' matches multiple workspaces" in result.stdout
assert "Choose one of these matching workspaces by slug" in result.stdout
assert "workspace: team-alpha" in result.stdout
assert "workspace: team-beta" in result.stdout
assert "tenant_id: tenant-team-alpha" in result.stdout
assert "tenant_id: tenant-team-beta" in result.stdout
assert "workspace: personal" not in result.stdout
def test_project_list_invalid_workspace_exits_without_local_fallback(
runner: CliRunner, write_config, tmp_path, monkeypatch
):
"""Invalid explicit workspace filters should stop instead of showing local-only rows."""
local_path = (tmp_path / "main").as_posix()
write_config(
{
"env": "dev",
"projects": {"main": {"path": local_path, "mode": "local"}},
"default_project": "main",
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
async def fake_get_available_workspaces():
return [personal]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
async def fake_list_projects(self):
return ProjectList.model_validate(
{
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "main",
"path": local_path,
"is_default": True,
}
],
"default_project": "main",
}
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(
app,
["project", "list", "--workspace", "missing"],
env={"COLUMNS": "240"},
)
assert result.exit_code == 1
assert "Workspace 'missing' not found" in result.stdout
assert "Basic Memory Projects" not in result.stdout
assert "Cloud project discovery failed" not in result.stdout
def test_project_list_attaches_local_state_to_one_duplicate_cloud_project(
runner: CliRunner, write_config, tmp_path, monkeypatch
):
"""Local path/default/sync state should not appear on every matching workspace."""
local_path = (tmp_path / "main").as_posix()
write_config(
{
"env": "dev",
"projects": {
"main": {
"path": local_path,
"mode": "local",
"local_sync_path": local_path,
}
},
"default_project": "main",
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
team = _workspace(
tenant_id="tenant-team",
slug="team",
name="Team",
workspace_type="organization",
)
async def fake_get_available_workspaces():
return [personal, team]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
payloads_by_workspace = {
None: {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "main",
"path": local_path,
"is_default": True,
}
],
"default_project": "main",
},
"tenant-personal": {
"projects": [
{
"id": 2,
"external_id": "22222222-2222-2222-2222-222222222222",
"name": "main",
"path": "/basic-memory",
"is_default": True,
}
],
"default_project": "main",
},
"tenant-team": {
"projects": [
{
"id": 3,
"external_id": "33333333-3333-3333-3333-333333333333",
"name": "main",
"path": "/basic-memory",
"is_default": True,
}
],
"default_project": "main",
},
}
async def fake_list_projects(self):
return ProjectList.model_validate(payloads_by_workspace[self.http_client.workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(app, ["project", "list", "--json"], env={"COLUMNS": "240"})
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
data = json.loads(result.stdout)
main_rows = [project for project in data["projects"] if project["name"] == "main"]
assert len(main_rows) == 2
personal_row = next(project for project in main_rows if project["workspace"] == "Personal")
team_row = next(project for project in main_rows if project["workspace"] == "Team")
assert personal_row["local_path"] == project_cmd.format_path(local_path)
assert personal_row["cli_route"] == "local"
assert personal_row["mcp_stdio"] == "stdio"
assert personal_row["sync"] is True
assert personal_row["is_default"] is True
assert team_row["local_path"] == ""
assert team_row["cli_route"] == "cloud"
assert team_row["mcp_stdio"] == "https"
assert team_row["sync"] is False
assert team_row["is_default"] is False
filtered_result = runner.invoke(
app,
["project", "list", "--workspace", "organization", "--json"],
env={"COLUMNS": "240"},
)
assert filtered_result.exit_code == 0, (
f"Exit code: {filtered_result.exit_code}, output: {filtered_result.stdout}"
)
filtered_data = json.loads(filtered_result.stdout)
assert len(filtered_data["projects"]) == 1
filtered_team_row = filtered_data["projects"][0]
assert filtered_team_row["workspace"] == "Team"
assert filtered_team_row["local_path"] == ""
assert filtered_team_row["cli_route"] == "cloud"
assert filtered_team_row["mcp_stdio"] == "https"
assert filtered_team_row["sync"] is False
assert filtered_team_row["is_default"] is False
def test_project_list_hides_bisync_flag_for_attached_team_workspace(
runner: CliRunner, write_config, tmp_path, monkeypatch
):
"""Bisync is only supported for personal workspaces."""
local_path = (tmp_path / "team-main").as_posix()
write_config(
{
"env": "dev",
"projects": {
"main": {
"path": local_path,
"mode": "cloud",
"workspace_id": "tenant-team",
"local_sync_path": local_path,
}
},
"default_project": "main",
"cloud_api_key": "bmc_test_key_123",
}
)
personal = _workspace(
tenant_id="tenant-personal",
slug="personal",
name="Personal",
workspace_type="personal",
is_default=True,
)
team = _workspace(
tenant_id="tenant-team",
slug="team",
name="Team",
workspace_type="organization",
)
async def fake_get_available_workspaces():
return [personal, team]
class FakeClient:
def __init__(self, workspace: str | None):
self.workspace = workspace
@asynccontextmanager
async def fake_get_client(workspace=None):
yield FakeClient(workspace)
project_payload = {
"projects": [
{
"id": 1,
"external_id": "11111111-1111-1111-1111-111111111111",
"name": "main",
"path": "/basic-memory",
"is_default": True,
}
],
"default_project": "main",
}
payloads_by_workspace = {
None: {"projects": [], "default_project": None},
"tenant-personal": project_payload,
"tenant-team": project_payload,
}
async def fake_list_projects(self):
return ProjectList.model_validate(payloads_by_workspace[self.http_client.workspace])
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
result = runner.invoke(app, ["project", "list", "--json"], env={"COLUMNS": "240"})
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
data = json.loads(result.stdout)
main_rows = [project for project in data["projects"] if project["name"] == "main"]
team_row = next(project for project in main_rows if project["workspace"] == "Team")
assert team_row["cli_route"] == "cloud"
assert team_row["mcp_stdio"] == "https"
assert team_row["sync"] is False
assert team_row["is_default"] is True
def test_project_ls_local_mode_defaults_to_local_route(
runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch
):
-40
View File
@@ -182,46 +182,6 @@ class TestWorkspaceSetDefault:
assert result.exit_code == 1
assert "not found" in result.stdout
def test_set_default_workspace_ambiguous_type_lists_matching_choices(self, runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team-alpha",
name="Team Alpha",
role="editor",
),
_workspace(
tenant_id="33333333-3333-3333-3333-333333333333",
workspace_type="organization",
slug="team-beta",
name="Team Beta",
role="owner",
),
]
monkeypatch.setattr(
workspace_cmd, "get_available_workspaces", fake_get_available_workspaces
)
result = runner.invoke(app, ["cloud", "workspace", "set-default", "organization"])
assert result.exit_code == 1
assert "Workspace 'organization' matches multiple workspaces" in result.stdout
assert "Choose one of these matching workspaces by slug" in result.stdout
assert "workspace: team-alpha" in result.stdout
assert "workspace: team-beta" in result.stdout
assert "workspace: personal" not in result.stdout
def test_set_default_workspace_no_workspaces(self, runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return []
@@ -128,51 +128,3 @@ def test_unicode_targets():
assert relation.type == "type"
assert relation.target == "Target"
assert relation.context == "测试"
def test_quoted_multi_word_relation_type_parses_as_explicit_relation():
"""Quoted relation labels allow explicit multi-word relation types."""
md = MarkdownIt().use(relation_plugin)
tokens = md.parse('- "some type" [[Target]] (context)')
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [
{"type": "some type", "target": "Target", "context": "context"}
]
assert parse_relation(token) == {"type": "some type", "target": "Target", "context": "context"}
def test_single_quoted_multi_word_relation_type_parses_as_explicit_relation():
"""Single-quoted relation labels also allow explicit multi-word relation types."""
md = MarkdownIt().use(relation_plugin)
tokens = md.parse("- 'some type' [[Target]] (context)")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [
{"type": "some type", "target": "Target", "context": "context"}
]
assert parse_relation(token) == {"type": "some type", "target": "Target", "context": "context"}
def test_unquoted_multi_word_prefix_is_inline_link_not_relation_type():
"""Unquoted prose before a wikilink is an inline link, not a relation type."""
md = MarkdownIt().use(relation_plugin)
tokens = md.parse("- some other thing [[Target]]")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [{"type": "links_to", "target": "Target", "context": None}]
assert parse_relation(token) is None
def test_bare_list_wikilink_is_inline_link_not_default_explicit_relation():
"""A list item containing only a wikilink is still a generic inline link."""
md = MarkdownIt().use(relation_plugin)
tokens = md.parse("- [[Target]]")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [{"type": "links_to", "target": "Target", "context": None}]
assert parse_relation(token) is None
-33
View File
@@ -133,39 +133,6 @@ class TestKnowledgeClient:
result = await client.resolve_entity("my-note")
assert result == "entity-uuid-123"
@pytest.mark.asyncio
async def test_get_orphans_validates_response(self, monkeypatch):
"""Orphan responses are validated into GraphNode objects."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
from basic_memory.schemas.v2.graph import GraphNode
mock_response = MagicMock()
mock_response.json.return_value = {
"entities": [
{
"external_id": "entity-uuid-123",
"title": "Orphan Note",
"file_path": "notes/orphan.md",
"note_type": "note",
}
],
"total": 1,
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/orphans" in url
return mock_response
monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.get_orphans()
assert len(result) == 1
assert isinstance(result[0], GraphNode)
assert result[0].title == "Orphan Note"
class TestSearchClient:
"""Tests for SearchClient."""
-25
View File
@@ -20,31 +20,6 @@ def mcp() -> FastMCP:
return cast(Any, mcp_server)
class ContextState:
"""Minimal FastMCP context-state stub for MCP tests."""
def __init__(self):
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
async def info(self, message: str) -> None:
self._state["info_message"] = message
def ctx(context: ContextState) -> Any:
return cast(Any, context)
@pytest.fixture
def context_state() -> ContextState:
return ContextState()
@pytest.fixture(scope="function")
def app(
app_config, project_config, engine_factory, config_manager
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -4,17 +4,30 @@ from __future__ import annotations
import importlib
from contextlib import contextmanager
from typing import Any, cast
import logfire
import pytest
from basic_memory.config import ProjectEntry
from basic_memory.schemas.cloud import WorkspaceInfo
from tests.mcp.conftest import ContextState, ctx
project_context = importlib.import_module("basic_memory.mcp.project_context")
class _ContextState:
"""Minimal FastMCP context-state stub for unit tests."""
def __init__(self) -> None:
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
def _capture_spans():
spans: list[tuple[str, dict]] = []
@@ -29,7 +42,7 @@ def _capture_spans():
@pytest.mark.asyncio
async def test_resolve_workspace_parameter_emits_routing_span(monkeypatch) -> None:
spans, fake_span = _capture_spans()
context = ContextState()
context = _ContextState()
workspace = WorkspaceInfo(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
@@ -45,7 +58,7 @@ async def test_resolve_workspace_parameter_emits_routing_span(monkeypatch) -> No
monkeypatch.setattr(logfire, "span", fake_span)
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
resolved = await project_context.resolve_workspace_parameter(context=ctx(context))
resolved = await project_context.resolve_workspace_parameter(context=cast(Any, context))
assert resolved.tenant_id == workspace.tenant_id
assert spans == [
+1 -31
View File
@@ -4,7 +4,7 @@ import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import build_context, write_note
from basic_memory.mcp.tools import build_context
@pytest.mark.asyncio
@@ -75,36 +75,6 @@ async def test_get_discussion_context_pattern(client, test_graph, test_project):
assert result["metadata"]["depth"] == 1
@pytest.mark.asyncio
async def test_build_context_project_id_preserves_workspace_contextvar_canonical_path(
app, test_project
):
"""project_id routing keeps ContextVar workspace prefixes in memory URL lookups."""
from basic_memory.workspace_context import workspace_permalink_context
with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"):
await write_note(
project_id=test_project.external_id,
title="Workspace Build Context Note",
directory="tests",
content="Build context should find this workspace note",
)
result = await build_context(
project_id=test_project.external_id,
url="memory://tests/*",
timeframe="30d",
)
assert isinstance(result, dict)
assert len(result["results"]) == 1
primary = result["results"][0]["primary_result"]
assert primary["permalink"] == (
f"team-paul/{test_project.name}/tests/workspace-build-context-note"
)
assert primary["content"] == "Build context should find this workspace note"
@pytest.mark.asyncio
async def test_get_discussion_context_timeframe(client, test_graph, test_project):
"""Test timeframe parameter filtering."""
+1 -3
View File
@@ -31,13 +31,12 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"output_format",
],
"delete_note": ["identifier", "is_directory", "project", "project_id", "output_format"],
"delete_project": ["project_name", "workspace"],
"delete_project": ["project_name"],
"edit_note": [
"identifier",
"operation",
"content",
"project",
"workspace",
"project_id",
"section",
"find_text",
@@ -84,7 +83,6 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"query",
"project",
"project_id",
"search_all_projects",
"page",
"page_size",
"search_type",
+8 -375
View File
@@ -9,68 +9,6 @@ from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
def test_edit_note_workspace_project_route_helper():
"""workspace/project routing should be explicit and deterministic."""
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
assert (
edit_note_module._compose_workspace_project_route(
workspace=None,
project="docs/setup",
project_id=None,
)
== "docs/setup"
)
assert (
edit_note_module._compose_workspace_project_route(
workspace="docs",
project="setup",
project_id=None,
)
== "docs/setup"
)
@pytest.mark.parametrize(
("route_kwargs", "message"),
[
(
{"workspace": " ", "project": "setup", "project_id": None},
"workspace must not be empty",
),
(
{"workspace": "docs/setup", "project": "setup", "project_id": None},
"workspace must be a single workspace",
),
(
{"workspace": "docs", "project": "setup", "project_id": "project-id"},
"workspace cannot be combined with project_id",
),
(
{"workspace": "docs", "project": None, "project_id": None},
"workspace requires an explicit project",
),
(
{"workspace": "docs", "project": "setup/install", "project_id": None},
"not both",
),
],
)
def test_edit_note_workspace_project_route_helper_rejects_invalid_inputs(
route_kwargs,
message,
):
"""Ambiguous workspace/project argument combinations should fail before routing."""
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
with pytest.raises(ValueError, match=message):
edit_note_module._compose_workspace_project_route(**route_kwargs)
@pytest.mark.asyncio
async def test_edit_note_append_operation(client, test_project):
"""Test appending content to an existing note."""
@@ -889,298 +827,6 @@ async def test_edit_note_workspace_qualified_memory_url_keeps_complete_permalink
assert f"permalink: {expected_permalink}" in result
@pytest.mark.asyncio
async def test_edit_note_workspace_qualified_plain_permalink_requires_explicit_route(
monkeypatch,
test_project,
):
"""Plain workspace-qualified write identifiers should stop before mutating."""
import importlib
from contextlib import asynccontextmanager
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
workspace_slug = "team-acme"
qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note"
detected_identifiers: list[str] = []
async def detect_workspace_project(identifier, config, context=None):
detected_identifiers.append(identifier)
return f"{workspace_slug}/{test_project.name}"
@asynccontextmanager
async def fail_if_called(*args, **kwargs):
raise AssertionError("ambiguous plain identifiers should not select a project client")
yield
monkeypatch.setattr(
edit_note_module,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
"detect_project_from_workspace_identifier_prefix",
detect_workspace_project,
raising=False,
)
monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called)
result = await edit_note(
identifier=qualified_identifier,
operation="append",
content="\nAppended via plain workspace-qualified permalink.",
project=None,
)
assert detected_identifiers == [qualified_identifier]
assert isinstance(result, str)
assert "# Edit Failed - Ambiguous Identifier" in result
assert f"`{qualified_identifier}` could refer to a local note path" in result
assert f'project="{workspace_slug}/{test_project.name}"' in result
assert f"memory://{qualified_identifier}" in result
@pytest.mark.asyncio
async def test_edit_note_workspace_qualified_plain_permalink_json_error(
monkeypatch,
test_project,
):
"""Ambiguous plain write identifiers should stay machine-readable in JSON mode."""
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
workspace_slug = "team-acme"
qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note"
async def detect_workspace_project(identifier, config, context=None):
assert identifier == qualified_identifier
return f"{workspace_slug}/{test_project.name}"
monkeypatch.setattr(
edit_note_module,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
"detect_project_from_workspace_identifier_prefix",
detect_workspace_project,
raising=False,
)
result = await edit_note(
identifier=qualified_identifier,
operation="append",
content="\nAppended via plain workspace-qualified permalink.",
output_format="json",
project=None,
)
assert isinstance(result, dict)
assert result["error"] == "AMBIGUOUS_IDENTIFIER"
assert result["project"] == f"{workspace_slug}/{test_project.name}"
assert result["fileCreated"] is False
@pytest.mark.asyncio
async def test_edit_note_ambiguous_namespace_identifier_returns_guidance(
monkeypatch,
test_project,
):
"""Namespace-style workspace identifiers should still return ambiguity guidance."""
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
workspace_slug = "team-acme"
identifier = f"{workspace_slug}::{test_project.name}/team/plain-edit-note"
normalized_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note"
async def detect_workspace_project(raw_identifier, config, context=None):
assert raw_identifier == identifier
return f"{workspace_slug}/{test_project.name}"
monkeypatch.setattr(
edit_note_module,
"_workspace_identifier_discovery_available",
lambda identifier, config: True,
)
monkeypatch.setattr(
edit_note_module,
"detect_project_from_workspace_identifier_prefix",
detect_workspace_project,
raising=False,
)
result = await edit_note(
identifier=identifier,
operation="append",
content="\nAppended via namespace-style plain identifier.",
project=None,
)
assert isinstance(result, str)
assert "# Edit Failed - Ambiguous Identifier" in result
assert f"`{identifier}` could refer to a local note path" in result
assert f"memory://{normalized_identifier}" in result
@pytest.mark.asyncio
async def test_edit_note_workspace_project_args_compose_explicit_route(monkeypatch):
"""workspace plus project should route like project='workspace/project'."""
from contextlib import asynccontextmanager
from types import SimpleNamespace
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
captured_routes: list[tuple[str | None, str | None]] = []
@asynccontextmanager
async def fake_get_project_client(project, context=None, project_id=None):
captured_routes.append((project, project_id))
yield object(), SimpleNamespace(name="setup")
monkeypatch.setattr(edit_note_module, "get_project_client", fake_get_project_client)
with pytest.raises(ValueError, match="Invalid operation"):
await edit_note(
identifier="install",
operation="invalid",
content="content",
workspace="docs",
project="setup",
)
assert captured_routes == [("docs/setup", None)]
@pytest.mark.asyncio
async def test_edit_note_plain_workspace_route_returns_guidance_with_local_config(
monkeypatch,
config_manager,
test_project,
):
"""Mixed local+cloud configs should still stop ambiguous plain write routes."""
from contextlib import asynccontextmanager
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
index = _build_workspace_project_index(
(personal,),
(
WorkspaceProjectEntry(
workspace=personal,
project=ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
),
),
),
)
async def fake_index(context=None):
return index
@asynccontextmanager
async def fail_if_called(*args, **kwargs):
raise AssertionError("ambiguous plain identifiers should not select a project client")
yield
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called)
result = await edit_note(
identifier="personal/main/team/plain-edit-note",
operation="append",
content="\nAppended via plain workspace-qualified permalink.",
project=None,
)
assert isinstance(result, str)
assert "# Edit Failed - Ambiguous Identifier" in result
assert 'project="personal/main"' in result
@pytest.mark.asyncio
async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_discovery(
monkeypatch,
client,
test_project,
):
"""Three-segment local paths should stay on the active project without discovery."""
import importlib
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
await write_note(
project=test_project.name,
title="Local Three Segment",
directory="folder/subdir",
content="# Local Three Segment\nOriginal content.",
)
async def fail_if_called(*args, **kwargs):
raise AssertionError("local three-segment paths should not trigger workspace detection")
monkeypatch.setattr(
edit_note_module,
"_workspace_identifier_discovery_available",
lambda identifier, config: False,
)
monkeypatch.setattr(
edit_note_module,
"detect_project_from_workspace_identifier_prefix",
fail_if_called,
)
result = await edit_note(
identifier="folder/subdir/local-three-segment",
operation="append",
content="\nAppended locally.",
project=None,
)
assert isinstance(result, str)
assert "Edited note (append)" in result
assert f"project: {test_project.name}" in result
updated = await read_note(
identifier="folder/subdir/local-three-segment",
project=test_project.name,
)
assert "Appended locally." in updated
@pytest.mark.asyncio
async def test_edit_note_skips_detection_for_plain_path(client, test_project):
"""edit_note should NOT call detect_project_from_url_prefix for plain path identifiers.
@@ -1188,12 +834,9 @@ async def test_edit_note_skips_detection_for_plain_path(client, test_project):
A plain path like 'research/note' should not be misrouted to a project
named 'research' the 'research' segment is a directory, not a project.
"""
with (
patch("basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix") as mock_url,
patch(
"basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix"
) as mock_workspace,
):
with patch(
"basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix"
) as mock_detect:
# Use a plain path (no memory:// prefix) — detection should not be called
await edit_note(
identifier="test/some-note",
@@ -1202,19 +845,15 @@ async def test_edit_note_skips_detection_for_plain_path(client, test_project):
project=None,
)
mock_url.assert_not_called()
mock_workspace.assert_not_called()
mock_detect.assert_not_called()
@pytest.mark.asyncio
async def test_edit_note_skips_detection_when_project_provided(client, test_project):
"""edit_note should skip URL detection when project is explicitly provided."""
with (
patch("basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix") as mock_url,
patch(
"basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix"
) as mock_workspace,
):
with patch(
"basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix"
) as mock_detect:
await edit_note(
identifier=f"memory://{test_project.name}/test/some-note",
operation="append",
@@ -1222,8 +861,7 @@ async def test_edit_note_skips_detection_when_project_provided(client, test_proj
project=test_project.name,
)
mock_url.assert_not_called()
mock_workspace.assert_not_called()
mock_detect.assert_not_called()
@pytest.mark.asyncio
@@ -1247,11 +885,6 @@ async def test_edit_note_skips_detection_when_project_id_provided(
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
monkeypatch.setattr(edit_note_module, "detect_project_from_memory_url_prefix", fail_if_called)
monkeypatch.setattr(
edit_note_module,
"detect_project_from_workspace_identifier_prefix",
fail_if_called,
)
result = await edit_note(
identifier=f"memory://{test_project.name}/test/project-id-memory-url-edit",
+9 -244
View File
@@ -1,9 +1,7 @@
"""Tests for MCP project management tools."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from sqlalchemy import select
@@ -138,6 +136,9 @@ async def test_create_and_delete_project_and_name_match_branch(
@pytest.mark.asyncio
async def test_create_memory_project_resolves_workspace_slug(app, tmp_path_factory):
"""A friendly workspace slug resolves to the tenant id used for cloud routing."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
@@ -206,6 +207,9 @@ async def test_create_memory_project_resolves_workspace_slug(app, tmp_path_facto
@pytest.mark.asyncio
async def test_create_memory_project_workspace_is_local_noop(app, tmp_path_factory):
"""Local create accepts workspace without requiring cloud workspace discovery."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
@@ -272,6 +276,9 @@ async def test_create_memory_project_workspace_is_local_noop(app, tmp_path_facto
@pytest.mark.asyncio
async def test_create_memory_project_default_workspace_is_none(app, tmp_path_factory):
"""When workspace is omitted, get_client receives workspace=None (default workspace)."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
@@ -321,248 +328,6 @@ async def test_create_memory_project_default_workspace_is_none(app, tmp_path_fac
assert captured["workspace"] is None
@pytest.mark.asyncio
async def test_create_memory_project_constrained_with_workspace_returns_disabled_message(
monkeypatch, tmp_path_factory
):
"""A constrained MCP session rejects creation before resolving a workspace selector."""
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "locked-project")
project_root = tmp_path_factory.mktemp("constrained-create-project-home")
with (
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
side_effect=RuntimeError("bad workspace"),
) as mock_resolve_workspace,
):
result = await create_memory_project(
project_name="Any Project",
project_path=str(project_root),
workspace="missing-team",
)
mock_resolve_workspace.assert_not_awaited()
assert "Project creation disabled" in result
assert "locked-project" in result
@pytest.mark.asyncio
async def test_delete_project_resolves_workspace_slug(app):
"""A friendly workspace slug resolves to the tenant id used for delete routing."""
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
target_project = _make_project(
"WS Project",
"/ws-project",
external_id="project-uuid",
)
captured: dict[str, str | None] = {}
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target_project,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
return_value=_make_workspace(
"tenant-abc-123",
"Team Paul",
workspace_type="organization",
slug="team-paul",
),
) as mock_resolve_workspace,
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target_project], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
) as mock_delete_project,
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
result = await delete_project("WS Project", workspace="team-paul")
mock_resolve_workspace.assert_awaited_once_with(workspace="team-paul", context=None)
assert captured["workspace"] == "tenant-abc-123"
mock_delete_project.assert_awaited_once_with("project-uuid")
assert result.startswith("")
@pytest.mark.asyncio
async def test_delete_project_workspace_is_local_noop(app):
"""Local delete accepts workspace without requiring cloud workspace discovery."""
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
target_project = _make_project(
"Local WS Project",
"/local-ws-project",
external_id="local-project-uuid",
)
captured: dict[str, str | None] = {}
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target_project,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=False,
),
patch(
"basic_memory.mcp.tools.project_management.has_cloud_credentials",
return_value=False,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
) as mock_resolve_workspace,
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target_project], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
),
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
await delete_project("Local WS Project", workspace="team-paul")
mock_resolve_workspace.assert_not_awaited()
assert captured["workspace"] == "team-paul"
@pytest.mark.asyncio
async def test_delete_project_default_workspace_is_none(app):
"""When workspace is omitted, delete_project routes through the default workspace."""
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
target_project = _make_project(
"Default WS Project",
"/default-ws-project",
external_id="default-project-uuid",
)
captured: dict[str, str | None] = {"workspace": "sentinel"}
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target_project,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target_project], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
),
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
await delete_project("Default WS Project")
assert captured["workspace"] is None
@pytest.mark.asyncio
async def test_delete_project_constrained_with_workspace_returns_disabled_message(monkeypatch):
"""A constrained MCP session rejects deletion before resolving a workspace selector."""
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "locked-project")
with (
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
side_effect=RuntimeError("bad workspace"),
) as mock_resolve_workspace,
):
result = await delete_project("Any Project", workspace="missing-team")
mock_resolve_workspace.assert_not_awaited()
assert "Project deletion disabled" in result
assert "locked-project" in result
# --- Cloud merge tests ---
-107
View File
@@ -6,10 +6,6 @@ We keep these tests focused on path boundary/security checks, and rely on
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
import pytest
from mcp.server.fastmcp.exceptions import ToolError
@@ -161,109 +157,6 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
assert "safe note" in result["text"]
@pytest.mark.asyncio
async def test_read_content_workspace_memory_url_routes_with_local_config(
monkeypatch,
config_manager,
):
"""Workspace-qualified memory URLs should route even when local projects exist."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
read_content_module = importlib.import_module("basic_memory.mcp.tools.read_content")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=project),),
)
async def fake_index(context=None):
return index
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
assert project == "personal/main"
assert project_id is None
yield (
object(),
SimpleNamespace(
name="main",
external_id="11111111-1111-1111-1111-111111111111",
home=Path("/tmp/main"),
),
)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
assert identifier == "memory://personal/main/docs/report"
assert project == "main"
return None, "personal/main/docs/report", True
async def fake_resolve_entity_id(client, project_id, url):
assert project_id == "11111111-1111-1111-1111-111111111111"
assert url == "personal/main/docs/report"
return "entity-1"
class FakeResponse:
headers = {"content-type": "text/markdown", "content-length": "17"}
text = "# Routed Content"
content = b"# Routed Content"
async def fake_call_get(client, path, **kwargs):
assert path == "/v2/projects/11111111-1111-1111-1111-111111111111/resource/entity-1"
return FakeResponse()
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(read_content_module, "get_project_client", fake_get_project_client)
monkeypatch.setattr(
read_content_module,
"resolve_project_and_path",
fake_resolve_project_and_path,
)
monkeypatch.setattr(read_content_module, "resolve_entity_id", fake_resolve_entity_id)
monkeypatch.setattr(read_content_module, "call_get", fake_call_get)
result = await read_content(path="memory://personal/main/docs/report")
assert result == {
"type": "text",
"text": "# Routed Content",
"content_type": "text/markdown",
"encoding": "utf-8",
}
@pytest.mark.asyncio
async def test_read_content_empty_path_does_not_trigger_security_error(client, test_project):
try:
+3 -192
View File
@@ -1,7 +1,5 @@
"""Tests for note tools that exercise the full stack with SQLite."""
from contextlib import asynccontextmanager
from types import SimpleNamespace
from textwrap import dedent
import pytest
@@ -9,7 +7,6 @@ import pytest
from basic_memory.mcp.tools import write_note, read_note
from basic_memory.mcp.tools.read_note import _parse_opening_frontmatter
from basic_memory.utils import normalize_newlines
from tests.mcp.conftest import ContextState, ctx
def test_parse_opening_frontmatter_handles_crlf():
@@ -169,130 +166,6 @@ async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch,
assert "existing note content" not in result
@pytest.mark.asyncio
async def test_read_note_explicit_workspace_project_ignores_stale_cached_project(
monkeypatch,
config_manager,
):
"""Explicit workspace routing should use the resolved cloud UUID, not stale cache."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import WorkspaceProjectEntry
from basic_memory.schemas.project_info import ProjectItem
read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
config = config_manager.load_config()
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = project_context.WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
expected_uuid = "22222222-2222-2222-2222-222222222222"
expected_project = ProjectItem(
id=2,
external_id=expected_uuid,
name="main",
path="/tmp/main",
is_default=False,
)
stale_project = ProjectItem(
id=99,
external_id="33333333-3333-3333-3333-333333333333",
name="main",
path="/tmp/stale-main",
is_default=False,
)
context = ContextState()
await context.set_state("active_workspace", personal.model_dump())
await context.set_state("active_project", stale_project.model_dump())
async def fake_resolve_workspace_project_identifier(project_name, context=None):
assert project_name == "personal/main"
return WorkspaceProjectEntry(workspace=personal, project=expected_project)
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
assert project_name == "main"
assert workspace == "personal-tenant"
yield object()
class FakeProjectResolveResponse:
def json(self):
return {
"external_id": expected_uuid,
"project_id": expected_project.id,
"name": expected_project.name,
"permalink": expected_project.permalink,
"path": expected_project.path,
"is_active": True,
"is_default": False,
"resolution_method": "permalink",
}
async def fake_call_post(*args, **kwargs):
return FakeProjectResolveResponse()
class FakeKnowledgeClient:
def __init__(self, client, project_id):
assert project_id == expected_uuid
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
assert identifier == "personal/main/todo"
return "entity-1"
async def get_entity(self, entity_id: str):
assert entity_id == "entity-1"
return SimpleNamespace(
title="TODO",
permalink="personal/main/todo",
file_path="TODO.md",
)
class FakeResourceClient:
def __init__(self, client, project_id):
assert project_id == expected_uuid
async def read(self, entity_id: str):
assert entity_id == "entity-1"
return SimpleNamespace(
status_code=200,
text="---\ntitle: TODO\n---\n\n# TODO - Priorities & Tasks\n",
)
monkeypatch.setattr(
project_context,
"resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
monkeypatch.setattr(clients_mod, "KnowledgeClient", FakeKnowledgeClient)
monkeypatch.setattr(clients_mod, "ResourceClient", FakeResourceClient)
result = await read_note_module.read_note(
"memory://todo",
project="personal/main",
output_format="json",
context=ctx(context),
)
assert isinstance(result, dict)
assert result["permalink"] == "personal/main/todo"
assert result["content"].strip() == "# TODO - Priorities & Tasks"
@pytest.mark.asyncio
async def test_note_unicode_content(app, test_project):
"""Test handling of unicode content in"""
@@ -419,7 +292,7 @@ async def test_read_note_skips_url_detection_when_project_id_provided(
import importlib
read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note")
monkeypatch.setattr(read_note_module, "detect_project_from_identifier_prefix", fail_if_called)
monkeypatch.setattr(read_note_module, "detect_project_from_memory_url_prefix", fail_if_called)
result = await read_note(
f"memory://{test_project.name}/test/project-id-memory-url-read",
@@ -507,14 +380,14 @@ async def test_team_workspace_write_stores_complete_permalink_when_project_prefi
@pytest.mark.asyncio
async def test_personal_workspace_write_stores_complete_canonical_permalink(
async def test_personal_workspace_write_keeps_project_scoped_permalink(
app,
test_project,
entity_repository,
):
from basic_memory.workspace_context import workspace_permalink_context
expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note"
expected_permalink = f"{test_project.name}/personal/personal-workspace-note"
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
write_result = await write_note(
@@ -531,68 +404,6 @@ async def test_personal_workspace_write_stores_complete_canonical_permalink(
assert stored.permalink == expected_permalink
@pytest.mark.asyncio
async def test_read_note_personal_workspace_keeps_short_project_permalink_working(
app,
test_project,
):
from basic_memory.workspace_context import workspace_permalink_context
legacy_permalink = f"{test_project.name}/personal/short-personal-note"
await write_note(
project=test_project.name,
title="Short Personal Note",
directory="personal",
content="Short personal workspace content",
)
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
read_result = await read_note(
f"memory://{legacy_permalink}",
project=test_project.name,
output_format="json",
)
assert isinstance(read_result, dict)
assert read_result["permalink"] == legacy_permalink
assert read_result["content"].strip() == "Short personal workspace content"
@pytest.mark.asyncio
async def test_read_note_workspace_qualified_personal_url_finds_legacy_short_permalink(
app,
test_project,
entity_repository,
):
from basic_memory.workspace_context import workspace_permalink_context
legacy_permalink = f"{test_project.name}/personal/legacy-personal-note"
qualified_permalink = f"personal/{legacy_permalink}"
await write_note(
project=test_project.name,
title="Legacy Personal Note",
directory="personal",
content="Legacy personal workspace content",
)
stored = await entity_repository.get_by_permalink(legacy_permalink)
assert stored is not None
assert stored.permalink == legacy_permalink
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
read_result = await read_note(
f"memory://{qualified_permalink}",
project=test_project.name,
output_format="json",
)
assert isinstance(read_result, dict)
assert read_result["permalink"] == legacy_permalink
assert read_result["content"].strip() == "Legacy personal workspace content"
@pytest.mark.asyncio
async def test_read_note_workspace_qualified_memory_url_uses_complete_permalink(
app,
-65
View File
@@ -116,71 +116,6 @@ async def test_schema_validate_json_output(app, test_project, sync_service):
assert result["results"][0]["passed"] is True
@pytest.mark.asyncio
async def test_schema_validate_picoschema_modifier_descriptions(app, test_project, sync_service):
"""Modifier descriptions should not become literal field names."""
project_path = Path(test_project.path)
_write_schema_file(
project_path,
"schemas/PicoTest.md",
"""\
---
title: PicoTest
type: schema
entity: pico_test
schema:
name: string
status(enum, current state): [active, inactive]
tags(array, list of tags): string
settings:
validation: warn
---
# PicoTest
""",
)
_write_schema_file(
project_path,
"pico/PicoTest1.md",
"""\
---
title: PicoTest1
type: pico_test
permalink: pico/pico-test-1
---
# PicoTest1
## Observations
- [name] PicoTest1
- [status] active
- [tags] foo
- [tags] bar
""",
)
await sync_service.sync(project_path)
result = await schema_validate(
note_type="pico_test",
project=test_project.name,
output_format="json",
)
assert isinstance(result, dict)
note_result = result["results"][0]
field_statuses = {fr["field_name"]: fr["status"] for fr in note_result["field_results"]}
assert result["valid_count"] == 1
assert note_result["warnings"] == []
assert note_result["unmatched_observations"] == {}
assert field_statuses == {
"name": "present",
"status": "present",
"tags": "present",
}
@pytest.mark.asyncio
async def test_schema_validate_by_identifier(app, test_project, sync_service):
"""Validate a specific note by identifier."""
+4 -126
View File
@@ -3,8 +3,6 @@
import pytest
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import cast
from basic_memory.mcp.tools import write_note
from basic_memory.mcp.tools.search import (
@@ -30,10 +28,7 @@ async def test_search_text(client, test_project):
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="searchable",
search_type="text",
output_format="json",
project=test_project.name, query="searchable", output_format="json"
)
# Verify results - handle both success and error cases
@@ -173,109 +168,6 @@ async def test_search_memory_url_with_project_prefix(client, test_project):
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_workspace_memory_url_routes_with_local_config(monkeypatch, config_manager):
"""Workspace-qualified memory URL searches should self-route in mixed mode."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.schemas.search import SearchItemType, SearchResult
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project_item = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=project_item),),
)
async def fake_index(context=None):
return index
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
captured["project"] = project
captured["project_id"] = project_id
yield object(), SimpleNamespace(name="main", external_id=project_item.external_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
assert identifier == "memory://personal/main/tests/search-note"
assert project == "main"
return None, "personal/main/tests/search-note", True
class FakeSearchClient:
def __init__(self, client, project_id):
captured["search_project_id"] = project_id
async def search(self, payload, *, page, page_size):
captured["payload"] = payload
return SearchResponse(
results=[
SearchResult(
title="Search Note",
type=SearchItemType.ENTITY,
score=1.0,
permalink="personal/main/tests/search-note",
file_path="tests/Search Note.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", FakeSearchClient)
response = await search_notes(
query="memory://personal/main/tests/search-note",
output_format="json",
)
assert captured["project"] == "personal/main"
assert captured["project_id"] is None
assert captured["search_project_id"] == "11111111-1111-1111-1111-111111111111"
payload = cast(dict[str, object], captured["payload"])
assert payload["permalink"] == "personal/main/tests/search-note"
assert isinstance(response, dict)
assert response["results"][0]["permalink"] == "personal/main/tests/search-note"
@pytest.mark.asyncio
async def test_search_pagination(client, test_project):
"""Test basic search functionality."""
@@ -291,12 +183,7 @@ async def test_search_pagination(client, test_project):
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="searchable",
search_type="text",
page=1,
page_size=1,
output_format="json",
project=test_project.name, query="searchable", page=1, page_size=1, output_format="json"
)
# Verify results - handle both success and error cases
@@ -325,11 +212,7 @@ async def test_search_with_type_filter(client, test_project):
# Search with note type filter (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="type",
search_type="text",
note_types=["note"],
output_format="json",
project=test_project.name, query="type", note_types=["note"], output_format="json"
)
# Verify results - handle both success and error cases
@@ -354,11 +237,7 @@ async def test_search_with_entity_type_filter(client, test_project):
# Search with entity_types (SearchItemType) filter (use json format)
response = await search_notes(
project=test_project.name,
query="type",
search_type="text",
entity_types=["entity"],
output_format="json",
project=test_project.name, query="type", entity_types=["entity"], output_format="json"
)
# Verify results - handle both success and error cases
@@ -386,7 +265,6 @@ async def test_search_with_date_filter(client, test_project):
response = await search_notes(
project=test_project.name,
query="recent",
search_type="text",
after_date=one_hour_ago.isoformat(),
output_format="json",
)
-1
View File
@@ -157,7 +157,6 @@ async def test_search_notes_emits_root_operation_and_project_context(
"tool_name": "search_notes",
"requested_project": test_project.name,
"requested_project_id": None,
"search_all_projects": False,
"search_type": "text",
"output_format": "json",
"page": 1,
-25
View File
@@ -13,11 +13,6 @@ from basic_memory.mcp.tools.utils import (
call_put,
get_error_message,
)
from basic_memory.workspace_context import (
WORKSPACE_SLUG_HEADER,
WORKSPACE_TYPE_HEADER,
workspace_permalink_context,
)
@pytest.fixture
@@ -175,26 +170,6 @@ async def test_call_get_with_params(mock_response):
assert kwargs["params"] == params
@pytest.mark.asyncio
async def test_call_post_adds_workspace_permalink_headers_at_request_time(mock_response):
client = _Client()
client.set_response("post", mock_response())
with workspace_permalink_context("team-paul", "organization"):
await call_post(
_client(client),
"http://test.com",
headers={"X-Existing": "value"},
)
assert len(client.calls) == 1
_, _args, kwargs = client.calls[0]
request_headers = kwargs["headers"]
assert request_headers["X-Existing"] == "value"
assert request_headers[WORKSPACE_SLUG_HEADER] == "team-paul"
assert request_headers[WORKSPACE_TYPE_HEADER] == "organization"
@pytest.mark.asyncio
async def test_get_error_message():
"""Test the get_error_message function."""
+19 -7
View File
@@ -1,11 +1,12 @@
"""Tests for workspace MCP tools."""
from typing import Any, cast
import pytest
from basic_memory.mcp.project_context import get_available_workspaces, set_workspace_provider
from basic_memory.mcp.tools.workspaces import list_workspaces
from basic_memory.schemas.cloud import WorkspaceInfo
from tests.mcp.conftest import ContextState, ctx
def _workspace(
@@ -27,6 +28,17 @@ def _workspace(
)
class _ContextState:
def __init__(self):
self._state: dict[str, object] = {}
async def get_state(self, key: str):
return self._state.get(key)
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
@pytest.mark.asyncio
async def test_list_workspaces_formats_workspace_rows(monkeypatch):
async def fake_get_available_workspaces(context=None):
@@ -133,7 +145,7 @@ async def test_list_workspaces_oauth_error_bubbles_up(monkeypatch):
@pytest.mark.asyncio
async def test_list_workspaces_uses_context_cache_path(monkeypatch):
context = ContextState()
context = _ContextState()
call_count = {"fetches": 0}
workspace = _workspace(
tenant_id="33333333-3333-3333-3333-333333333333",
@@ -157,8 +169,8 @@ async def test_list_workspaces_uses_context_cache_path(monkeypatch):
fake_get_available_workspaces,
)
first = await list_workspaces(context=ctx(context))
second = await list_workspaces(context=ctx(context))
first = await list_workspaces(context=cast(Any, context))
second = await list_workspaces(context=cast(Any, context))
assert "# Available Workspaces (1)" in first
assert "# Available Workspaces (1)" in second
@@ -242,14 +254,14 @@ async def test_get_available_workspaces_provider_caches_in_context():
return [workspace]
set_workspace_provider(counting_provider)
context = ContextState()
context = _ContextState()
# First call: provider is invoked, result cached
first = await get_available_workspaces(context=ctx(context))
first = await get_available_workspaces(context=cast(Any, context))
assert len(first) == 1
assert call_count["provider"] == 1
# Second call: served from context cache, provider not called again
second = await get_available_workspaces(context=ctx(context))
second = await get_available_workspaces(context=cast(Any, context))
assert len(second) == 1
assert call_count["provider"] == 1
+2 -8
View File
@@ -7,7 +7,6 @@ import pytest
from basic_memory import config as config_module
from basic_memory.mcp.tools import write_note, read_note, delete_note
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.utils import normalize_newlines
@@ -310,7 +309,7 @@ async def test_write_note_with_tag_array_from_bug_report(app, test_project):
@pytest.mark.asyncio
async def test_write_note_verbose(app, test_project, engine_factory):
async def test_write_note_verbose(app, test_project):
"""Test creating a new note.
Should:
@@ -327,7 +326,7 @@ async def test_write_note_verbose(app, test_project, engine_factory):
# Test\nThis is a test note
- [note] First observation
- "relates to" [[Knowledge]]
- relates to [[Knowledge]]
""",
tags=["test", "documentation"],
@@ -344,11 +343,6 @@ async def test_write_note_verbose(app, test_project, engine_factory):
assert "- test, documentation" in result
assert f"[Session: Using project '{test_project.name}']" in result
_, session_maker = engine_factory
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
relations = await relation_repository.find_by_type("relates to")
assert any(relation.to_name == "Knowledge" for relation in relations)
@pytest.mark.asyncio
async def test_write_note_preserves_custom_metadata(app, project_config, test_project):
@@ -1,102 +0,0 @@
"""Regression tests for workspace-qualified permalink resolution."""
import pytest
from basic_memory.mcp.tools.delete_note import delete_note
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.workspace_context import workspace_permalink_context
@pytest.mark.asyncio
async def test_link_resolver_strictly_finds_legacy_project_permalink_from_workspace_url(
link_resolver,
test_project,
entity_service,
):
"""Workspace/project IDs should resolve legacy project-prefixed rows centrally."""
await write_note(
project=test_project.name,
title="Resolver Legacy Workspace Note",
directory="personal",
content="Resolver legacy content",
)
legacy_permalink = f"{test_project.name}/personal/resolver-legacy-workspace-note"
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
resolved = await link_resolver.resolve_link(
f"personal/{legacy_permalink}",
strict=True,
)
assert resolved is not None
assert resolved.permalink == legacy_permalink
@pytest.mark.asyncio
async def test_edit_note_workspace_qualified_url_edits_legacy_project_permalink(
app,
test_project,
):
"""IDs returned from multi-project search should be editable even for legacy rows."""
legacy_permalink = f"{test_project.name}/personal/edit-legacy-workspace-note"
qualified_permalink = f"personal/{legacy_permalink}"
await write_note(
project=test_project.name,
title="Edit Legacy Workspace Note",
directory="personal",
content="# Edit Legacy Workspace Note\n\nstatus: draft",
)
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
edit_result = await edit_note(
identifier=f"memory://{qualified_permalink}",
project=test_project.name,
operation="find_replace",
find_text="status: draft",
content="status: done",
output_format="json",
)
assert isinstance(edit_result, dict)
assert edit_result["fileCreated"] is False
assert edit_result["permalink"] == legacy_permalink
read_result = await read_note(
legacy_permalink,
project=test_project.name,
output_format="json",
)
assert isinstance(read_result, dict)
assert "status: done" in read_result["content"]
@pytest.mark.asyncio
async def test_delete_note_workspace_qualified_url_deletes_legacy_project_permalink(
app,
test_project,
):
"""IDs returned from multi-project search should be deletable even for legacy rows."""
legacy_permalink = f"{test_project.name}/personal/delete-legacy-workspace-note"
qualified_permalink = f"personal/{legacy_permalink}"
await write_note(
project=test_project.name,
title="Delete Legacy Workspace Note",
directory="personal",
content="Delete legacy content",
)
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
delete_result = await delete_note(
identifier=f"memory://{qualified_permalink}",
project=test_project.name,
output_format="json",
)
assert isinstance(delete_result, dict)
assert delete_result["deleted"] is True
assert delete_result["permalink"] == legacy_permalink
-50
View File
@@ -85,35 +85,6 @@ async def test_search_uses_dynamic_default_search_type(monkeypatch, client, test
assert "search_type" not in captured_kwargs
@pytest.mark.asyncio
async def test_search_delegates_to_search_notes_without_project_iteration(
monkeypatch, client, test_project
):
"""ChatGPT search is only a compatibility wrapper around search_notes."""
import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools
captured_kwargs: dict = {}
async def fake_search_notes_fn(*args, **kwargs):
captured_kwargs.update(kwargs)
return {"results": []}
monkeypatch.setattr(chatgpt_tools, "search_notes", fake_search_notes_fn)
result = await chatgpt_tools.search("MCP Test Note")
content = json.loads(result[0]["text"])
assert content["results"] == []
assert content["query"] == "MCP Test Note"
assert captured_kwargs == {
"query": "MCP Test Note",
"page": 1,
"page_size": 10,
"output_format": "json",
"context": None,
}
@pytest.mark.asyncio
async def test_fetch_successful_document(client, test_project):
"""Test fetch with successful document retrieval."""
@@ -156,27 +127,6 @@ async def test_fetch_document_not_found(client, test_project):
assert content["metadata"]["error"] == "Document not found"
@pytest.mark.asyncio
async def test_fetch_routes_path_ids_as_memory_urls(monkeypatch, client, test_project):
"""Workspace-qualified search ids need memory URL routing during fetch."""
import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools
captured: dict[str, str] = {}
async def fake_read_note(*, identifier: str, context=None):
captured["identifier"] = identifier
return "# MCP Test Note\n\nFetched from the requested workspace."
monkeypatch.setattr(chatgpt_tools, "read_note", fake_read_note)
result = await chatgpt_tools.fetch("team-paul/main/tests/mcp-test-note")
content = json.loads(result[0]["text"])
assert captured["identifier"] == "memory://team-paul/main/tests/mcp-test-note"
assert content["id"] == "team-paul/main/tests/mcp-test-note"
assert content["title"] == "MCP Test Note"
def test_format_search_results_for_chatgpt():
"""Test search results formatting."""
from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt

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