mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 214a54d740 | |||
| 5a90c7cedf | |||
| 9e3fe26a83 | |||
| 47ee982041 | |||
| 4d22c398c6 | |||
| 8ac2d975f9 | |||
| 34830bfad7 | |||
| f5e0c42047 | |||
| 3f98da8c67 | |||
| 14ff77d1c2 | |||
| 4e4da6128d | |||
| 3bed6d8890 | |||
| 4cba7ba01c | |||
| 8eeec64e28 | |||
| c6fa185bf3 | |||
| 415c2b3d6e | |||
| 4aa0cbdd62 | |||
| 55f314237d | |||
| 9862ef5411 | |||
| df5e8d805f | |||
| 831dc1ecdc | |||
| 26381aeed1 | |||
| 7918e5c6bf | |||
| f312341020 | |||
| e871298e95 | |||
| 177ae21ba7 | |||
| 3415fd1014 | |||
| b08659e228 | |||
| 09a4b09436 |
@@ -10,6 +10,9 @@ on:
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Only run for organization members and collaborators
|
||||
@@ -27,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -12,7 +15,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -68,4 +71,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"'
|
||||
|
||||
@@ -12,6 +12,9 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
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 }}
|
||||
@@ -65,4 +68,3 @@ 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:*)'
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
dev-release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -50,4 +53,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
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: basicmachines-co/basic-memory
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -19,17 +20,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -58,4 +59,3 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ on:
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -38,4 +41,4 @@ jobs:
|
||||
deps
|
||||
installer
|
||||
# Allow breaking changes (needs "!" after type/scope)
|
||||
requireScopeForBreakingChange: true
|
||||
requireScopeForBreakingChange: true
|
||||
|
||||
@@ -5,6 +5,9 @@ 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
|
||||
@@ -13,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -39,7 +42,7 @@ jobs:
|
||||
echo "Build completed successfully"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/*.whl
|
||||
@@ -64,7 +67,7 @@ jobs:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Update Homebrew formula
|
||||
uses: mislav/bump-homebrew-formula-action@v3
|
||||
uses: mislav/bump-homebrew-formula-action@v4.1
|
||||
with:
|
||||
# Formula name in homebrew-basic-memory repo
|
||||
formula-name: basic-memory
|
||||
@@ -82,4 +85,3 @@ jobs:
|
||||
env:
|
||||
# Personal Access Token with repo scope for homebrew-basic-memory repo
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
|
||||
|
||||
+23
-20
@@ -11,6 +11,9 @@ 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)
|
||||
@@ -18,12 +21,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -32,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -52,7 +55,7 @@ jobs:
|
||||
|
||||
test-sqlite-unit:
|
||||
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -68,12 +71,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -82,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -114,12 +117,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -144,7 +147,7 @@ jobs:
|
||||
|
||||
test-postgres-unit:
|
||||
name: Test Postgres Unit (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -171,12 +174,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@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -185,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -228,12 +231,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@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -242,7 +245,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -262,12 +265,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -276,7 +279,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
|
||||
+118
-1
@@ -1,6 +1,123 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
## 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.
|
||||
|
||||
## v0.20.3 (2026-03-26)
|
||||
|
||||
|
||||
+67
-2
@@ -8,6 +8,71 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
If you find a vulnerability, please contact hello@basicmachines.co.
|
||||
|
||||
If you find a vulnerability, please contact hello@basicmachines.co
|
||||
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.
|
||||
|
||||
+26
-8
@@ -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 relation instead |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a `links_to` relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
@@ -116,47 +116,65 @@ 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.
|
||||
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.
|
||||
|
||||
**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` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `relation_type` | Yes | Single unquoted token before `[[`, or quoted text for multi-word labels. |
|
||||
| `[[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)
|
||||
- [[Some Entity]]
|
||||
- "based on" [[Customer Interview]]
|
||||
- 'in response to' [[Incident Review]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
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.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
Any single-token text or quoted text works as a relation type. These are conventions,
|
||||
not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
Wiki links appearing in regular prose create implicit `links_to` relations. This includes
|
||||
list items that do not match the explicit relation grammar above.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
- We should revisit [[Search Design]] after the API changes.
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
This creates three relations: `links_to [[Core Design]]`, `links_to [[Utility Functions]]`,
|
||||
and `links_to [[Search Design]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
|
||||
+96
-25
@@ -94,13 +94,18 @@ bm cloud setup
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
1. Installs rclone automatically (if needed)
|
||||
1. Installs rclone with a supported package manager (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:
|
||||
@@ -485,6 +490,9 @@ 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
|
||||
|
||||
@@ -574,33 +582,62 @@ bm cloud logout
|
||||
**Default patterns:**
|
||||
|
||||
```gitignore
|
||||
# Version control
|
||||
.git/**
|
||||
|
||||
# Python
|
||||
__pycache__/**
|
||||
*.pyc
|
||||
.venv/**
|
||||
venv/**
|
||||
|
||||
# Node.js
|
||||
node_modules/**
|
||||
# Hidden files and directories
|
||||
.*
|
||||
|
||||
# Basic Memory internals
|
||||
memory.db/**
|
||||
memory.db-shm/**
|
||||
memory.db-wal/**
|
||||
config.json/**
|
||||
watch-status.json/**
|
||||
.bmignore.rclone/**
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
config.json
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.svn
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.coverage
|
||||
*.egg-info
|
||||
.tox
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
|
||||
# Build artifacts
|
||||
build
|
||||
dist
|
||||
.cache
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# OS files
|
||||
.DS_Store/**
|
||||
Thumbs.db/**
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Environment files
|
||||
.env/**
|
||||
.env.local/**
|
||||
# Obsidian
|
||||
.obsidian
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
@@ -609,6 +646,11 @@ Thumbs.db/**
|
||||
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
|
||||
@@ -616,7 +658,7 @@ Thumbs.db/**
|
||||
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
|
||||
@@ -624,6 +666,33 @@ 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"
|
||||
@@ -759,8 +828,10 @@ 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`
|
||||
|
||||
@@ -785,7 +856,7 @@ bm cloud create-key <name> # Create API key via cloud API (requires OAuth login
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
bm cloud setup # Install rclone and configure credentials
|
||||
bm cloud setup # Install rclone via package manager and configure credentials
|
||||
```
|
||||
|
||||
### Project Management
|
||||
|
||||
+3
-2
@@ -25,11 +25,12 @@ dependencies = [
|
||||
"unidecode>=1.3.8",
|
||||
"dateparser>=1.2.0",
|
||||
"watchfiles>=1.0.4",
|
||||
"fastapi[standard]>=0.115.8",
|
||||
"fastapi[standard]>=0.136.1",
|
||||
"alembic>=1.14.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=3.0.1,<4",
|
||||
# Keep FastMCP pinned until each minor upgrade passes the MCP transport matrix.
|
||||
"fastmcp==3.3.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.20.3",
|
||||
"version": "0.21.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.20.3",
|
||||
"version": "0.21.0",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.20.3"
|
||||
__version__ = "0.21.0"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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,6 +10,7 @@ import logging
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status, Path
|
||||
|
||||
from basic_memory.deps import (
|
||||
AppConfigDep,
|
||||
ChatGPTImporterV2ExternalDep,
|
||||
ClaudeConversationsImporterV2ExternalDep,
|
||||
ClaudeProjectsImporterV2ExternalDep,
|
||||
@@ -27,9 +28,21 @@ 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"),
|
||||
@@ -49,12 +62,13 @@ 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)
|
||||
return await import_file(importer, file, directory, config.import_upload_max_bytes)
|
||||
|
||||
|
||||
@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"),
|
||||
@@ -74,12 +88,13 @@ 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)
|
||||
return await import_file(importer, file, directory, config.import_upload_max_bytes)
|
||||
|
||||
|
||||
@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"),
|
||||
@@ -99,12 +114,13 @@ 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)
|
||||
return await import_file(importer, file, directory, config.import_upload_max_bytes)
|
||||
|
||||
|
||||
@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"),
|
||||
@@ -126,7 +142,7 @@ async def import_memory_json(
|
||||
logger.info(f"V2 Importing memory.json for project {project_id}")
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
file_bytes = await read_import_upload(file, config.import_upload_max_bytes)
|
||||
file_str = file_bytes.decode("utf-8")
|
||||
for line in file_str.splitlines():
|
||||
json_data = json.loads(line)
|
||||
@@ -138,6 +154,8 @@ 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(
|
||||
@@ -147,13 +165,16 @@ async def import_memory_json(
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
|
||||
async def import_file(
|
||||
importer: Importer, file: UploadFile, destination_directory: str, max_bytes: int
|
||||
):
|
||||
"""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
|
||||
@@ -163,7 +184,8 @@ async def import_file(importer: Importer, file: UploadFile, destination_director
|
||||
"""
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
upload_bytes = await read_import_upload(file, max_bytes)
|
||||
json_data = json.loads(upload_bytes)
|
||||
result = await importer.import_data(json_data, destination_directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
@@ -173,6 +195,8 @@ async def import_file(importer: Importer, file: UploadFile, destination_director
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -38,6 +38,7 @@ from basic_memory.schemas.v2 import (
|
||||
MoveEntityRequestV2,
|
||||
MoveDirectoryRequestV2,
|
||||
DeleteDirectoryRequestV2,
|
||||
OrphanEntitiesResponse,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
|
||||
@@ -110,6 +111,38 @@ 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 Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body, Query, Path
|
||||
from loguru import logger
|
||||
@@ -25,6 +25,8 @@ 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,
|
||||
@@ -36,6 +38,71 @@ 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)
|
||||
@@ -247,28 +314,10 @@ async def resolve_project_identifier(
|
||||
"""
|
||||
logger.info(f"API v2 request: resolve_project_identifier for '{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
|
||||
project, resolution_method = await _resolve_project_identifier(
|
||||
project_repository,
|
||||
data.identifier,
|
||||
)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
||||
|
||||
@@ -4,6 +4,8 @@ This router uses external_id UUIDs for stable, API-friendly routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
|
||||
import logfire
|
||||
@@ -12,7 +14,7 @@ from basic_memory.repository.semantic_errors import (
|
||||
SemanticDependenciesMissingError,
|
||||
SemanticSearchDisabledError,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse, SearchRetrievalMode
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
@@ -65,7 +67,7 @@ async def search(
|
||||
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
|
||||
):
|
||||
offset = (page - 1) * page_size
|
||||
fetch_limit = page_size + 1
|
||||
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
|
||||
try:
|
||||
with logfire.span(
|
||||
"api.search.search.execute_query",
|
||||
@@ -75,7 +77,14 @@ async def search(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
results = await search_service.search(query, limit=fetch_limit, offset=offset)
|
||||
if exact_count_available:
|
||||
results, total = await asyncio.gather(
|
||||
search_service.search(query, limit=page_size, offset=offset),
|
||||
search_service.count(query),
|
||||
)
|
||||
else:
|
||||
results = await search_service.search(query, limit=page_size + 1, offset=offset)
|
||||
total = 0
|
||||
except SemanticSearchDisabledError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SemanticDependenciesMissingError as exc:
|
||||
@@ -90,9 +99,15 @@ async def search(
|
||||
phase="paginate_results",
|
||||
result_count=len(results),
|
||||
):
|
||||
has_more = len(results) > page_size
|
||||
if has_more:
|
||||
results = results[:page_size]
|
||||
if exact_count_available:
|
||||
has_more = offset + len(results) < total
|
||||
else:
|
||||
# Trigger: semantic modes would need another vector/hybrid retrieval to count.
|
||||
# Why: search requests should not pay for a second semantic pass.
|
||||
# Outcome: preserve probe pagination for semantic search and leave total at 0.
|
||||
has_more = len(results) > page_size
|
||||
if has_more:
|
||||
results = results[:page_size]
|
||||
|
||||
with logfire.span(
|
||||
"api.search.search.hydrate_results",
|
||||
@@ -113,6 +128,7 @@ async def search(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from basic_memory.services.context_service import (
|
||||
|
||||
|
||||
class EntityBatchLookup(Protocol):
|
||||
async def find_by_ids(self, ids: List[int]) -> Sequence[Any]: ...
|
||||
async def find_by_ids_for_hydration(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 all entities at once - get both title and external_id
|
||||
# Batch fetch just the entity fields needed to shape the response.
|
||||
entity_title_lookup: dict[int, str] = {}
|
||||
entity_external_id_lookup: dict[int, str] = {}
|
||||
if entity_ids_needed:
|
||||
@@ -87,7 +87,9 @@ async def to_graph_context(
|
||||
phase="lookup_entities",
|
||||
result_count=len(entity_ids_needed),
|
||||
):
|
||||
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
|
||||
entities = await entity_repository.find_by_ids_for_hydration(
|
||||
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,6 +1,6 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
|
||||
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans
|
||||
from . import (
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
@@ -18,6 +18,7 @@ __all__ = [
|
||||
"import_memory_json",
|
||||
"mcp",
|
||||
"import_claude_conversations",
|
||||
"orphans",
|
||||
"import_claude_projects",
|
||||
"import_chatgpt",
|
||||
"tool",
|
||||
|
||||
@@ -14,6 +14,24 @@ 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:
|
||||
@@ -78,19 +96,11 @@ def convert_bmignore_to_rclone_filters() -> Path:
|
||||
patterns.append(line)
|
||||
continue
|
||||
|
||||
# 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}/**")
|
||||
patterns.extend(_rclone_exclude_filters(line))
|
||||
|
||||
except Exception:
|
||||
# If we can't read the file, create a minimal filter
|
||||
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
|
||||
patterns = ["# Error reading .bmignore, using minimal filters", "- .git", "- .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 Optional
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
@@ -53,7 +53,9 @@ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedP
|
||||
|
||||
|
||||
def install_rclone_macos() -> None:
|
||||
"""Install rclone on macOS using Homebrew or official script."""
|
||||
"""Install rclone on macOS using package managers."""
|
||||
install_errors: list[str] = []
|
||||
|
||||
# Try Homebrew first
|
||||
if shutil.which("brew"):
|
||||
try:
|
||||
@@ -61,35 +63,37 @@ def install_rclone_macos() -> None:
|
||||
run_command(["brew", "install", "rclone"])
|
||||
console.print("[green]rclone installed via Homebrew[/green]")
|
||||
return
|
||||
except RcloneInstallError:
|
||||
console.print(
|
||||
"[yellow]Homebrew installation failed, trying official script...[/yellow]"
|
||||
)
|
||||
except RcloneInstallError as exc:
|
||||
install_errors.append(f"Homebrew failed: {exc}")
|
||||
console.print("[yellow]Homebrew installation failed, trying MacPorts...[/yellow]")
|
||||
|
||||
# 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"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
def install_rclone_linux() -> None:
|
||||
"""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]")
|
||||
"""Install rclone on Linux using package managers."""
|
||||
install_errors: list[str] = []
|
||||
|
||||
# Try apt (Debian/Ubuntu)
|
||||
if shutil.which("apt"):
|
||||
try:
|
||||
console.print("[blue]Installing rclone via apt...[/blue]")
|
||||
@@ -97,18 +101,75 @@ def install_rclone_linux() -> None:
|
||||
run_command(["sudo", "apt", "install", "-y", "rclone"])
|
||||
console.print("[green]rclone installed via apt[/green]")
|
||||
return
|
||||
except RcloneInstallError:
|
||||
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
|
||||
except RcloneInstallError as exc:
|
||||
install_errors.append(f"apt failed: {exc}")
|
||||
console.print("[yellow]apt installation failed, trying dnf...[/yellow]")
|
||||
|
||||
# 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"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
def install_rclone_windows() -> None:
|
||||
@@ -209,27 +270,38 @@ def refresh_windows_path() -> None:
|
||||
if platform.system().lower() != "windows":
|
||||
return
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
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.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]
|
||||
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)
|
||||
except Exception:
|
||||
user_path = ""
|
||||
|
||||
# Read system PATH
|
||||
try:
|
||||
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]
|
||||
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)
|
||||
except Exception:
|
||||
system_path = ""
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ 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 (
|
||||
_workspace_choices,
|
||||
_workspace_matches_identifier,
|
||||
get_available_workspaces,
|
||||
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,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
@@ -62,7 +63,10 @@ def list_workspaces() -> None:
|
||||
|
||||
@workspace_app.command("set-default")
|
||||
def set_default_workspace(
|
||||
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
|
||||
identifier: str = typer.Argument(
|
||||
...,
|
||||
help="Workspace name, slug, type, or tenant_id to set as default",
|
||||
),
|
||||
) -> None:
|
||||
"""Set the default cloud workspace.
|
||||
|
||||
@@ -71,6 +75,7 @@ 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
|
||||
"""
|
||||
|
||||
@@ -87,19 +92,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{_workspace_choices(workspaces)}[/dim]")
|
||||
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 '{identifier}' matches multiple workspaces.[/red]")
|
||||
console.print(
|
||||
f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. "
|
||||
f"Use tenant_id instead.[/red]"
|
||||
"[dim]Choose one of these matching workspaces by slug:\n"
|
||||
f"{format_workspace_selection_choices(matches)}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
selected = matches[0]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -33,6 +34,10 @@ 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
|
||||
@@ -281,27 +286,25 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
|
||||
|
||||
|
||||
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
|
||||
"""Resolve a workspace name or tenant_id to a tenant_id."""
|
||||
"""Resolve a workspace name, slug, type, 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{_workspace_choices(workspaces)}[/dim]")
|
||||
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}' matches multiple workspaces.[/red]")
|
||||
console.print(
|
||||
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
|
||||
f"Use tenant_id instead.[/red]"
|
||||
"[dim]Choose one of these matching workspaces by slug:\n"
|
||||
f"{format_workspace_selection_choices(matches)}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
return matches[0].tenant_id
|
||||
|
||||
@@ -312,9 +315,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:
|
||||
except Exception as exc:
|
||||
# Workspace resolution is optional until a command needs a specific tenant.
|
||||
pass
|
||||
logger.debug("Workspace resolution failed: {}", exc)
|
||||
|
||||
return None
|
||||
|
||||
@@ -323,7 +326,11 @@ 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 or tenant_id"),
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name, slug, type, 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."""
|
||||
@@ -339,17 +346,90 @@ def list_projects(
|
||||
|
||||
try:
|
||||
config = ConfigManager().config
|
||||
# Use explicit workspace, fall back to config default
|
||||
effective_workspace = workspace or config.default_workspace
|
||||
workspace_filter = workspace
|
||||
workspace_filter_requested = workspace_filter is not None
|
||||
|
||||
local_result: ProjectList | None = None
|
||||
cloud_result: ProjectList | None = None
|
||||
cloud_results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
|
||||
available_cloud_workspaces: list[WorkspaceInfo] = []
|
||||
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_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
(
|
||||
cloud_results,
|
||||
available_cloud_workspaces,
|
||||
cloud_workspace_error,
|
||||
failed_cloud_workspaces,
|
||||
) = _fetch_cloud_workspace_results()
|
||||
elif local:
|
||||
with force_routing(local=True):
|
||||
local_result = run_with_cleanup(_list_projects())
|
||||
@@ -362,29 +442,17 @@ def list_projects(
|
||||
try:
|
||||
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
(
|
||||
cloud_results,
|
||||
available_cloud_workspaces,
|
||||
cloud_workspace_error,
|
||||
failed_cloud_workspaces,
|
||||
) = _fetch_cloud_workspace_results()
|
||||
except typer.Exit:
|
||||
raise
|
||||
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")
|
||||
@@ -395,29 +463,126 @@ def list_projects(
|
||||
table.add_column("Sync", style="green")
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
project_names_by_permalink: dict[str, str] = {}
|
||||
row_names_by_key: dict[tuple[str | None, str], str] = {}
|
||||
local_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
cloud_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] = {}
|
||||
|
||||
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
|
||||
|
||||
if cloud_result:
|
||||
for cloud_workspace, cloud_result in cloud_results:
|
||||
workspace_key = cloud_workspace.tenant_id if cloud_workspace else None
|
||||
for project in cloud_result.projects:
|
||||
permalink = generate_permalink(project.name)
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
cloud_projects_by_permalink[permalink] = project
|
||||
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)
|
||||
|
||||
# --- Build unified project list ---
|
||||
project_rows: list[dict] = []
|
||||
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)
|
||||
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
|
||||
|
||||
local_path = ""
|
||||
if local_project is not None:
|
||||
@@ -447,9 +612,13 @@ def list_projects(
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = config.default_project == project_name
|
||||
default_permalink = (
|
||||
generate_permalink(config.default_project) if config.default_project else None
|
||||
)
|
||||
is_default = bool(is_attached_row and permalink == default_permalink)
|
||||
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
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)
|
||||
# Determine MCP transport based on project routing mode
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
mcp_transport = "https"
|
||||
@@ -459,9 +628,8 @@ def list_projects(
|
||||
mcp_transport = "stdio"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
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
|
||||
cloud_ws_name = cloud_workspace.name if cloud_workspace else None
|
||||
cloud_ws_type = cloud_workspace.workspace_type if cloud_workspace else None
|
||||
|
||||
# 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;
|
||||
@@ -481,8 +649,8 @@ def list_projects(
|
||||
}
|
||||
if display_name:
|
||||
row_data["display_name"] = display_name
|
||||
if ws_label:
|
||||
row_data["workspace"] = cloud_ws_name or ""
|
||||
if cloud_project is not None and cloud_ws_name:
|
||||
row_data["workspace"] = cloud_ws_name
|
||||
if cloud_ws_type:
|
||||
row_data["workspace_type"] = cloud_ws_type
|
||||
|
||||
@@ -514,6 +682,20 @@ 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)
|
||||
@@ -531,7 +713,7 @@ def add_project(
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name or tenant_id (cloud mode only)",
|
||||
help="Cloud workspace name, slug, type, or tenant_id (cloud mode only)",
|
||||
),
|
||||
visibility: str = typer.Option(
|
||||
None,
|
||||
@@ -914,7 +1096,7 @@ def set_cloud(
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name or tenant_id to associate with this project",
|
||||
help="Cloud workspace name, slug, type, or tenant_id to associate with this project",
|
||||
),
|
||||
) -> None:
|
||||
"""Set a project to cloud mode (route through cloud API).
|
||||
|
||||
@@ -24,6 +24,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
import_claude_projects,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
orphans,
|
||||
project,
|
||||
schema,
|
||||
status,
|
||||
|
||||
@@ -24,10 +24,24 @@ APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
|
||||
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."""
|
||||
|
||||
@@ -168,13 +182,15 @@ 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(
|
||||
@@ -278,6 +294,11 @@ 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.",
|
||||
@@ -776,6 +797,7 @@ 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:
|
||||
@@ -888,6 +910,7 @@ 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)
|
||||
|
||||
@@ -1043,9 +1066,12 @@ 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}")
|
||||
|
||||
|
||||
@@ -85,6 +85,28 @@ 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
|
||||
@@ -92,7 +114,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
|
||||
return "[[" in content and "]]" in content and parse_relation_type(content) is not None
|
||||
|
||||
|
||||
def parse_relation(token: Token) -> Dict[str, Any] | None:
|
||||
@@ -101,20 +123,18 @@ 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("]]")
|
||||
end = content.find("]]", start + 2)
|
||||
|
||||
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())
|
||||
|
||||
@@ -217,6 +237,8 @@ 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
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
|
||||
@@ -37,8 +36,18 @@ 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
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from basic_memory.schemas.response import (
|
||||
DirectoryMoveResult,
|
||||
DirectoryDeleteResult,
|
||||
)
|
||||
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
|
||||
|
||||
|
||||
class KnowledgeClient:
|
||||
@@ -275,6 +276,24 @@ 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:
|
||||
|
||||
@@ -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, Optional, List, Tuple, cast
|
||||
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
|
||||
from uuid import UUID
|
||||
|
||||
from httpx import AsyncClient
|
||||
@@ -25,11 +25,22 @@ 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
|
||||
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.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import generate_permalink, normalize_project_reference
|
||||
from basic_memory.utils import (
|
||||
build_qualified_permalink_reference,
|
||||
generate_permalink,
|
||||
normalize_project_reference,
|
||||
)
|
||||
from basic_memory.workspace_context import (
|
||||
current_workspace_permalink_context,
|
||||
workspace_permalink_context,
|
||||
@@ -162,6 +173,17 @@ async def _set_cached_active_workspace(
|
||||
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:
|
||||
@@ -284,29 +306,6 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
|
||||
return [project.name for project in project_list.projects]
|
||||
|
||||
|
||||
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
|
||||
"""Return True when identifier matches workspace tenant_id, 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):
|
||||
@@ -406,14 +405,20 @@ def _unqualified_project_identifier(identifier: str) -> str:
|
||||
return project_identifier
|
||||
|
||||
|
||||
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 _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
|
||||
|
||||
normalized = normalize_project_reference(memory_url_path(identifier))
|
||||
|
||||
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("/")
|
||||
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
|
||||
@@ -422,28 +427,81 @@ def _split_workspace_memory_url_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 == "organization":
|
||||
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
|
||||
elif workspace_type == "personal":
|
||||
prefix = project_permalink if include_project else ""
|
||||
else:
|
||||
if workspace_type not in {"organization", "personal"}:
|
||||
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
|
||||
|
||||
if not prefix:
|
||||
return normalized_remainder
|
||||
# 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 normalized_remainder:
|
||||
return prefix
|
||||
return f"{prefix}/{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}"
|
||||
|
||||
|
||||
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
|
||||
@@ -476,6 +534,27 @@ 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(
|
||||
@@ -525,12 +604,11 @@ async def resolve_workspace_qualified_memory_url(
|
||||
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: tuple[WorkspaceProjectEntry, ...]) -> str:
|
||||
def _format_qualified_choices(entries: Sequence[WorkspaceProjectEntry]) -> str:
|
||||
"""Format qualified project choices for collision errors."""
|
||||
return " or ".join(entry.qualified_name for entry in entries)
|
||||
|
||||
@@ -656,7 +734,7 @@ async def _ensure_workspace_project_index(
|
||||
)
|
||||
continue
|
||||
|
||||
workspace_entries = cast(tuple[WorkspaceProjectEntry, ...], result)
|
||||
workspace_entries = result
|
||||
successful_fetches += 1
|
||||
entries_list.extend(workspace_entries)
|
||||
|
||||
@@ -759,7 +837,7 @@ async def resolve_workspace_project_identifier(
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
matches = index.entries_by_permalink.get(project_permalink, ())
|
||||
matches = list(index.entries_by_permalink.get(project_permalink, ()))
|
||||
if not matches:
|
||||
failed_note = ""
|
||||
if index.failed_workspaces:
|
||||
@@ -893,7 +971,9 @@ 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_identifier(cached_workspace, workspace):
|
||||
if workspace is None or workspace_matches_exact_identifier(
|
||||
cached_workspace, workspace
|
||||
):
|
||||
logger.debug(
|
||||
f"Using cached workspace from context: {cached_workspace.tenant_id}"
|
||||
)
|
||||
@@ -909,19 +989,17 @@ 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{_workspace_choices(workspaces)}"
|
||||
f"Available workspaces:\n{format_workspace_choices(workspaces)}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"Workspace name '{workspace}' matches multiple workspaces. "
|
||||
"Use tenant_id instead.\n"
|
||||
f"Available workspaces:\n{_workspace_choices(workspaces)}"
|
||||
f"Workspace '{workspace}' matches multiple workspaces. "
|
||||
"Choose one of these matching workspaces by slug or tenant_id:\n"
|
||||
f"{format_workspace_selection_choices(matches)}"
|
||||
)
|
||||
selected_workspace = matches[0]
|
||||
elif len(workspaces) == 1:
|
||||
@@ -929,8 +1007,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.\n"
|
||||
f"Available workspaces:\n{_workspace_choices(workspaces)}"
|
||||
"with the 'workspace' argument set to the tenant_id or unique name/slug/type.\n"
|
||||
f"Available workspaces:\n{format_workspace_choices(workspaces)}"
|
||||
)
|
||||
|
||||
await _set_cached_active_workspace(context, selected_workspace)
|
||||
@@ -1073,7 +1151,6 @@ 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
|
||||
|
||||
@@ -1096,7 +1173,6 @@ 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
|
||||
|
||||
@@ -1115,8 +1191,11 @@ async def resolve_project_and_path(
|
||||
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
|
||||
)
|
||||
|
||||
resolved_path = (
|
||||
f"{cached_project.permalink}/{remainder}" if include_project else remainder
|
||||
resolved_path = _canonical_memory_path_for_active_route(
|
||||
cached_project,
|
||||
remainder,
|
||||
include_project=include_project,
|
||||
cached_workspace=cached_workspace,
|
||||
)
|
||||
return cached_project, resolved_path, True
|
||||
|
||||
@@ -1151,25 +1230,24 @@ async def resolve_project_and_path(
|
||||
)
|
||||
await _set_cached_active_project(context, active_project)
|
||||
|
||||
resolved_path = (
|
||||
f"{resolved.permalink}/{remainder}" if include_project else remainder
|
||||
resolved_path = _canonical_memory_path_for_active_route(
|
||||
active_project,
|
||||
remainder,
|
||||
include_project=include_project,
|
||||
cached_workspace=cached_workspace,
|
||||
)
|
||||
return active_project, resolved_path, True
|
||||
|
||||
# Trigger: no resolvable project prefix in the memory URL
|
||||
# Why: preserve existing memory URL behavior within the active project
|
||||
# Outcome: use the active project and normalize the path for lookup
|
||||
# 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
|
||||
active_project = await get_active_project(client, project, context, headers)
|
||||
resolved_path = normalized_path
|
||||
if include_project:
|
||||
# Trigger: project-prefixed permalinks are enabled and the path lacks a prefix
|
||||
# Why: ensure memory URL lookups align with canonical permalinks
|
||||
# Outcome: prefix the path with the active project's permalink
|
||||
project_prefix = active_project.permalink
|
||||
if resolved_path != project_prefix and not resolved_path.startswith(
|
||||
f"{project_prefix}/"
|
||||
):
|
||||
resolved_path = f"{project_prefix}/{resolved_path}"
|
||||
resolved_path = _canonical_memory_path_for_active_route(
|
||||
active_project,
|
||||
normalized_path,
|
||||
include_project=include_project,
|
||||
cached_workspace=cached_workspace,
|
||||
)
|
||||
return active_project, resolved_path, True
|
||||
|
||||
|
||||
@@ -1227,17 +1305,51 @@ 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 _cloud_workspace_discovery_available(config):
|
||||
resolution = await resolve_workspace_qualified_memory_url(
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
)
|
||||
if resolution is not None:
|
||||
return resolution.project_identifier
|
||||
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 "not found" in message or "no accessible workspaces" in message:
|
||||
return None
|
||||
raise
|
||||
|
||||
return project_resolution.qualified_name
|
||||
|
||||
return None
|
||||
|
||||
@@ -1325,6 +1437,7 @@ 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,
|
||||
@@ -1397,6 +1510,7 @@ 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,
|
||||
|
||||
@@ -6,7 +6,7 @@ a list containing a single `{"type": "text", "text": "{...json...}"}` item.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
@@ -17,18 +17,30 @@ 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] | list[dict[str, Any]] | dict[str, Any],
|
||||
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format search results according to ChatGPT's expected schema.
|
||||
|
||||
Returns a list of result objects with id, title, and url fields.
|
||||
"""
|
||||
if isinstance(results, SearchResponse):
|
||||
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
|
||||
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
|
||||
elif isinstance(results, dict):
|
||||
nested_results = results.get("results")
|
||||
raw_results = nested_results if isinstance(nested_results, list) else []
|
||||
raw_results = (
|
||||
cast(list[SearchResult | dict[str, Any]], nested_results)
|
||||
if isinstance(nested_results, list)
|
||||
else []
|
||||
)
|
||||
else:
|
||||
raw_results = results
|
||||
|
||||
@@ -113,8 +125,7 @@ async def search(
|
||||
logger.info(f"ChatGPT search request: query='{query}'")
|
||||
|
||||
try:
|
||||
# Let search_notes resolve the default project via get_project_client(),
|
||||
# which works in both local mode (ConfigManager) and cloud mode (database).
|
||||
# Keep this adapter tiny: the real search behavior lives in search_notes.
|
||||
results = await search_notes(
|
||||
query=query,
|
||||
page=1,
|
||||
@@ -123,7 +134,6 @@ 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 = {
|
||||
@@ -131,16 +141,17 @@ async def search(
|
||||
"error": "Search failed",
|
||||
"error_details": results[:500], # Truncate long error messages
|
||||
}
|
||||
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 [{"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")
|
||||
|
||||
# Return in MCP content array format as required by OpenAI
|
||||
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
|
||||
@@ -180,7 +191,7 @@ async def fetch(
|
||||
# which works in both local mode (ConfigManager) and cloud mode (database).
|
||||
content = str(
|
||||
await read_note(
|
||||
identifier=id,
|
||||
identifier=_identifier_for_read_note(id),
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
_cloud_workspace_discovery_available,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
add_project_metadata,
|
||||
@@ -17,7 +18,11 @@ 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.utils import validate_project_path
|
||||
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
|
||||
|
||||
|
||||
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
|
||||
@@ -47,6 +52,58 @@ 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,
|
||||
@@ -181,6 +238,7 @@ 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[
|
||||
@@ -224,7 +282,10 @@ 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().
|
||||
@@ -287,18 +348,52 @@ 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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
# 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 _cloud_workspace_discovery_available(
|
||||
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
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
|
||||
@@ -369,11 +369,11 @@ def _format_constrained_text(constrained_project: str) -> str:
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_create_project_workspace(
|
||||
async def _resolve_workspace_routing(
|
||||
workspace: str | None,
|
||||
context: Context | None,
|
||||
) -> str | None:
|
||||
"""Resolve the create-project workspace selector to the routing tenant id."""
|
||||
"""Resolve an optional workspace selector to the routing tenant id."""
|
||||
if workspace is None:
|
||||
return None
|
||||
|
||||
@@ -389,7 +389,8 @@ async def _resolve_create_project_workspace(
|
||||
# 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 at create time and pass only the tenant id downstream.
|
||||
# Outcome: resolve once before the project-management request and pass only
|
||||
# the tenant id downstream.
|
||||
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
|
||||
return resolved_workspace.tenant_id
|
||||
|
||||
@@ -418,8 +419,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`. Only meaningful in cloud mode; ignored for local
|
||||
projects.
|
||||
via `list_workspaces`. In local mode the selector is passed through
|
||||
without slug resolution.
|
||||
output_format: "text" returns the existing human-readable result text.
|
||||
"json" returns structured project creation metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
@@ -432,31 +433,34 @@ 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")
|
||||
"""
|
||||
workspace_id = await _resolve_create_project_workspace(workspace, context)
|
||||
# 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 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}")
|
||||
|
||||
@@ -536,7 +540,11 @@ async def create_memory_project(
|
||||
@mcp.tool(
|
||||
annotations={"destructiveHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def delete_project(project_name: str, context: Context | None = None) -> str:
|
||||
async def delete_project(
|
||||
project_name: str,
|
||||
workspace: str | None = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Delete a Basic Memory project.
|
||||
|
||||
Removes a project from the configuration and database. This does NOT delete
|
||||
@@ -545,23 +553,33 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
|
||||
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.
|
||||
"""
|
||||
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}\"`"
|
||||
# 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}\"`"
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_memory_url_prefix,
|
||||
detect_project_from_identifier_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 memory URL prefix before routing.
|
||||
# Detect project from a memory URL or permalink 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_memory_url_prefix(
|
||||
detected = await detect_project_from_identifier_prefix(
|
||||
identifier,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
@@ -287,7 +287,8 @@ async def read_note(
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"Returning read_note result from resource: {path}", path=entity_path
|
||||
"Returning read_note result from resource: {path}",
|
||||
path=entity_path,
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
|
||||
@@ -187,7 +187,9 @@ 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)
|
||||
resolved_project = await resolve_project_parameter(
|
||||
effective_identifier, allow_discovery=True, context=context
|
||||
)
|
||||
|
||||
if resolved_project is None:
|
||||
# Discovery Mode: Get activity across all projects
|
||||
|
||||
@@ -2,18 +2,24 @@
|
||||
|
||||
import re
|
||||
from textwrap import dedent
|
||||
from typing import Annotated, List, Optional, Dict, Any, Literal
|
||||
from typing import Annotated, List, Optional, Dict, Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, BeforeValidator, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.utils import coerce_dict, coerce_list
|
||||
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.mcp.container import get_container
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_memory_url_prefix,
|
||||
detect_project_from_identifier_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -22,6 +28,7 @@ from basic_memory.schemas.search import (
|
||||
SearchItemType,
|
||||
SearchQuery,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
SearchRetrievalMode,
|
||||
)
|
||||
|
||||
@@ -294,6 +301,266 @@ 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
|
||||
@@ -309,6 +576,13 @@ 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.
|
||||
@@ -373,6 +647,8 @@ 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
|
||||
|
||||
@@ -448,6 +724,8 @@ 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:
|
||||
@@ -551,10 +829,10 @@ async def search_notes(
|
||||
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
|
||||
query = remainder or None
|
||||
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# Detect project from a memory URL or permalink 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_memory_url_prefix(
|
||||
detected = await detect_project_from_identifier_prefix(
|
||||
query,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
@@ -562,12 +840,35 @@ 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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import typing
|
||||
from typing import Any, Optional
|
||||
|
||||
import logfire
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError, Headers
|
||||
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
|
||||
from httpx._types import (
|
||||
RequestContent,
|
||||
@@ -58,6 +58,22 @@ 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:
|
||||
@@ -224,7 +240,7 @@ async def call_get(
|
||||
response = await client.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
@@ -328,7 +344,7 @@ async def call_put(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
@@ -432,7 +448,7 @@ async def call_patch(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
@@ -542,7 +558,7 @@ async def call_post(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
@@ -667,7 +683,7 @@ async def call_delete(
|
||||
response = await client.delete(
|
||||
url=url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
|
||||
@@ -66,10 +66,14 @@ async def write_note(
|
||||
|
||||
Relations format:
|
||||
- Explicit: `- relation_type [[Entity]] (optional context)`
|
||||
- Inline: Any `[[Entity]]` reference creates a relation
|
||||
- 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
|
||||
|
||||
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 select, func
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import load_only, selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
from sqlalchemy.engine import Row
|
||||
|
||||
@@ -178,6 +178,24 @@ 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.
|
||||
|
||||
@@ -436,6 +454,22 @@ 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,11 +5,17 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
from typing import Any, Iterable, List
|
||||
from typing import Any, Iterable, List, cast
|
||||
|
||||
|
||||
_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)
|
||||
@@ -48,6 +54,11 @@ 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.
|
||||
|
||||
@@ -73,7 +84,12 @@ 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}")
|
||||
op, value = next(iter(raw_value.items()))
|
||||
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
|
||||
|
||||
if op == "$in":
|
||||
if not isinstance(value, list) or not value:
|
||||
@@ -83,15 +99,20 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
|
||||
)
|
||||
continue
|
||||
|
||||
if op in {"$gt", "$gte", "$lt", "$lte"}:
|
||||
if op in _COMPARISON_OPERATORS:
|
||||
if _is_numeric_value(value):
|
||||
normalized = float(value)
|
||||
normalized = _normalize_numeric(value)
|
||||
comparison = "numeric"
|
||||
else:
|
||||
normalized = _normalize_scalar(value)
|
||||
comparison = "text"
|
||||
parsed.append(
|
||||
ParsedMetadataFilter(path_parts, op.lstrip("$"), normalized, comparison)
|
||||
ParsedMetadataFilter(
|
||||
path_parts,
|
||||
_COMPARISON_OPERATORS[op],
|
||||
normalized,
|
||||
comparison,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -99,7 +120,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 = [float(v) for v in value]
|
||||
normalized = [_normalize_numeric(v) for v in value]
|
||||
comparison = "numeric"
|
||||
else:
|
||||
normalized = [_normalize_scalar(v) for v in value]
|
||||
|
||||
@@ -686,7 +686,17 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
# FTS search (Postgres-specific)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search(
|
||||
@staticmethod
|
||||
def _is_tsquery_syntax_error(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return (
|
||||
"syntax error in tsquery" in msg
|
||||
or "invalid input syntax for type tsquery" in msg
|
||||
or "no operand in tsquery" in msg
|
||||
or "no operator in tsquery" in msg
|
||||
)
|
||||
|
||||
async def _build_fts_query_parts(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
@@ -696,31 +706,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using PostgreSQL tsvector."""
|
||||
# --- Dispatch vector / hybrid modes (shared logic) ---
|
||||
dispatched = await self._dispatch_retrieval_mode(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
if dispatched is not None:
|
||||
return dispatched
|
||||
|
||||
# --- FTS mode (Postgres-specific) ---
|
||||
) -> tuple[str, str, dict, str, str]:
|
||||
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
@@ -787,7 +774,8 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
# Handle date filter
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("search_index.created_at > :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")
|
||||
# order by most recent first
|
||||
order_by_clause = ", search_index.updated_at DESC"
|
||||
|
||||
@@ -868,10 +856,6 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("search_index.project_id = :project_id")
|
||||
|
||||
# set limit and offset
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
@@ -884,6 +868,64 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
else:
|
||||
score_expr = "0"
|
||||
|
||||
return from_clause, where_clause, params, order_by_clause, score_expr
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using PostgreSQL tsvector."""
|
||||
# --- Dispatch vector / hybrid modes (shared logic) ---
|
||||
dispatched = await self._dispatch_retrieval_mode(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
if dispatched is not None:
|
||||
return dispatched
|
||||
|
||||
# --- FTS mode (Postgres-specific) ---
|
||||
(
|
||||
from_clause,
|
||||
where_clause,
|
||||
params,
|
||||
order_by_clause,
|
||||
score_expr,
|
||||
) = await self._build_fts_query_parts(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
)
|
||||
|
||||
# set limit and offset
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
search_index.project_id,
|
||||
@@ -904,7 +946,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
{score_expr} as score
|
||||
FROM {from_clause}
|
||||
WHERE {where_clause}
|
||||
ORDER BY score DESC, search_index.id ASC {order_by_clause}
|
||||
ORDER BY score DESC {order_by_clause}, search_index.id ASC
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
@@ -915,17 +957,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle tsquery syntax errors (and only those).
|
||||
#
|
||||
# Important: Postgres errors for other failures (e.g. missing table) will still mention
|
||||
# `to_tsquery(...)` in the SQL text, so checking for the substring "tsquery" is too broad.
|
||||
msg = str(e).lower()
|
||||
if (
|
||||
"syntax error in tsquery" in msg
|
||||
or "invalid input syntax for type tsquery" in msg
|
||||
or "no operand in tsquery" in msg
|
||||
or "no operator in tsquery" in msg
|
||||
):
|
||||
if self._is_tsquery_syntax_error(e):
|
||||
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
|
||||
return []
|
||||
|
||||
@@ -966,3 +998,60 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def count(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Count indexed content matching the Postgres FTS query."""
|
||||
if retrieval_mode != SearchRetrievalMode.FTS:
|
||||
return await super().count(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
)
|
||||
|
||||
(
|
||||
from_clause,
|
||||
where_clause,
|
||||
params,
|
||||
_order_by_clause,
|
||||
_score_expr,
|
||||
) = await self._build_fts_query_parts(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
)
|
||||
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
|
||||
logger.trace(f"Count {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
return int(result.scalar_one())
|
||||
except Exception as e:
|
||||
if self._is_tsquery_syntax_error(e):
|
||||
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
|
||||
return 0
|
||||
logger.error(f"Database error during search count: {e}")
|
||||
raise
|
||||
|
||||
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
from typing import Optional, Sequence, Union
|
||||
|
||||
|
||||
from sqlalchemy import text
|
||||
from loguru import logger
|
||||
from sqlalchemy import inspect as sa_inspect, select, text
|
||||
from sqlalchemy.exc import NoResultFound, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
@@ -12,6 +14,65 @@ 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.
|
||||
|
||||
@@ -121,6 +182,83 @@ 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.
|
||||
|
||||
|
||||
@@ -50,6 +50,22 @@ class SearchRepository(Protocol):
|
||||
"""Search across indexed content."""
|
||||
...
|
||||
|
||||
async def count(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Count indexed content matching the same filters as search."""
|
||||
...
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index a single item."""
|
||||
...
|
||||
|
||||
@@ -247,6 +247,24 @@ class SearchRepositoryBase(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def count(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Count results when a backend-specific COUNT query is available."""
|
||||
if retrieval_mode != SearchRetrievalMode.FTS:
|
||||
raise ValueError("Exact counts are only supported for full-text search retrieval.")
|
||||
raise NotImplementedError("Backend search repositories must implement full-text counts.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Abstract methods — semantic search (backend-specific DB operations)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -701,7 +701,11 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
# FTS search (backend-specific)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search(
|
||||
@staticmethod
|
||||
def _is_fts5_syntax_error(exc: Exception) -> bool:
|
||||
return "fts5: syntax error" in str(exc).lower()
|
||||
|
||||
async def _build_fts_query_parts(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
@@ -711,31 +715,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using SQLite FTS5."""
|
||||
# --- Dispatch vector / hybrid modes (shared logic) ---
|
||||
dispatched = await self._dispatch_retrieval_mode(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
if dispatched is not None:
|
||||
return dispatched
|
||||
|
||||
# --- FTS mode (SQLite-specific) ---
|
||||
) -> tuple[str, str, dict, str]:
|
||||
"""Build SQLite FTS FROM/WHERE params shared by search and count."""
|
||||
conditions = []
|
||||
match_conditions = []
|
||||
params = {}
|
||||
@@ -808,7 +789,8 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(search_index.created_at) > datetime(: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)")
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", search_index.updated_at DESC"
|
||||
@@ -911,13 +893,60 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("search_index.project_id = :project_id")
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
return from_clause, where_clause, params, order_by_clause
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using SQLite FTS5."""
|
||||
# --- Dispatch vector / hybrid modes (shared logic) ---
|
||||
dispatched = await self._dispatch_retrieval_mode(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
if dispatched is not None:
|
||||
return dispatched
|
||||
|
||||
# --- FTS mode (SQLite-specific) ---
|
||||
from_clause, where_clause, params, order_by_clause = await self._build_fts_query_parts(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
)
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
search_index.project_id,
|
||||
@@ -950,7 +979,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
if self._is_fts5_syntax_error(e): # pragma: no cover
|
||||
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
@@ -988,3 +1017,54 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def count(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
||||
min_similarity: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Count indexed content matching the SQLite FTS query."""
|
||||
if retrieval_mode != SearchRetrievalMode.FTS:
|
||||
return await super().count(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=min_similarity,
|
||||
)
|
||||
|
||||
from_clause, where_clause, params, _order_by_clause = await self._build_fts_query_parts(
|
||||
search_text=search_text,
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
)
|
||||
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
|
||||
logger.trace(f"Count {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
return int(result.scalar_one())
|
||||
except Exception as e:
|
||||
if self._is_fts5_syntax_error(e): # pragma: no cover
|
||||
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
||||
return 0
|
||||
logger.error(f"Database error during search count: {e}")
|
||||
raise
|
||||
|
||||
@@ -8,7 +8,9 @@ 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
|
||||
@@ -58,46 +60,88 @@ 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(key: str) -> tuple[str, bool, bool, bool, bool]:
|
||||
def _parse_field_key_parts(key: str) -> tuple[str, bool, bool, bool, bool, str | None]:
|
||||
"""Parse a Picoschema field key into its components.
|
||||
|
||||
Returns (name, required, is_array, is_enum, is_object).
|
||||
The key format is: name[?][(array|enum|object)]
|
||||
Returns (name, required, is_array, is_enum, is_object, description).
|
||||
The key format is: name[?][(array|enum|object[, description])]
|
||||
|
||||
Examples:
|
||||
"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
|
||||
"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)
|
||||
"""
|
||||
required = True
|
||||
is_array = False
|
||||
is_enum = False
|
||||
is_object = False
|
||||
description = None
|
||||
|
||||
# Check for modifier suffix: (array), (enum), (object)
|
||||
if key.endswith("(array)"):
|
||||
key, modifier, description = _split_modifier_suffix(key)
|
||||
|
||||
if modifier == "array":
|
||||
is_array = True
|
||||
key = key[: -len("(array)")]
|
||||
elif key.endswith("(enum)"):
|
||||
elif modifier == "enum":
|
||||
is_enum = True
|
||||
key = key[: -len("(enum)")]
|
||||
elif key.endswith("(object)"):
|
||||
elif modifier == "object":
|
||||
is_object = True
|
||||
key = key[: -len("(object)")]
|
||||
|
||||
# Check for optional marker
|
||||
if key.endswith("?"):
|
||||
required = False
|
||||
key = key[:-1]
|
||||
|
||||
return key, required, is_array, is_enum, is_object
|
||||
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
|
||||
|
||||
|
||||
def _parse_type_and_description(value: str) -> tuple[str, str | None]:
|
||||
@@ -170,7 +214,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 = _parse_field_key(key)
|
||||
name, required, is_array, is_enum, is_object, key_description = _parse_field_key_parts(key)
|
||||
|
||||
# --- Enum fields ---
|
||||
# Trigger: value is a list or a string containing bracketed enum values
|
||||
@@ -179,11 +223,12 @@ 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 = None
|
||||
description = key_description
|
||||
if isinstance(value, list):
|
||||
enum_values = [str(v) for v in value]
|
||||
else:
|
||||
enum_values, description = _parse_enum_string(str(value))
|
||||
enum_values, value_description = _parse_enum_string(str(value))
|
||||
description = description or value_description
|
||||
fields.append(
|
||||
SchemaField(
|
||||
name=name,
|
||||
@@ -207,13 +252,15 @@ 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, description = _parse_type_and_description(str(value))
|
||||
type_str, value_description = _parse_type_and_description(str(value))
|
||||
description = key_description or value_description
|
||||
is_entity_ref = _is_entity_ref_type(type_str)
|
||||
|
||||
fields.append(
|
||||
|
||||
@@ -88,6 +88,51 @@ 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."""
|
||||
|
||||
|
||||
@@ -142,4 +142,5 @@ class SearchResponse(BaseModel):
|
||||
results: List[SearchResult]
|
||||
current_page: int
|
||||
page_size: int
|
||||
total: int = 0
|
||||
has_more: bool = False
|
||||
|
||||
@@ -14,6 +14,7 @@ from basic_memory.schemas.v2.graph import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphResponse,
|
||||
OrphanEntitiesResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"GraphEdge",
|
||||
"GraphNode",
|
||||
"GraphResponse",
|
||||
"OrphanEntitiesResponse",
|
||||
"CreateResourceRequest",
|
||||
"UpdateResourceRequest",
|
||||
"ResourceResponse",
|
||||
|
||||
@@ -29,3 +29,12 @@ 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,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Any, 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 = {
|
||||
params: dict[str, Any] = {
|
||||
"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) # pyright: ignore # pragma: no cover
|
||||
params["since_date"] = since_utc.replace(tzinfo=None) # pragma: no cover
|
||||
else:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
params["since_date"] = since.isoformat()
|
||||
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"
|
||||
|
||||
@@ -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, Exception):
|
||||
# Type narrowing: resolved is Optional[Entity] here, not Exception
|
||||
target_entity = resolved # pyright: ignore [reportAssignmentType]
|
||||
if not isinstance(resolved, BaseException):
|
||||
# asyncio.gather(..., return_exceptions=True) can return any BaseException.
|
||||
target_entity = resolved
|
||||
|
||||
if target_entity is None and not resolve_targets:
|
||||
target_entity = await self._resolve_deferred_self_relation(rel.target, entity)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Service for resolving markdown links to permalinks."""
|
||||
"""Service and helpers for resolving markdown links and permalink-like identifiers."""
|
||||
|
||||
import uuid as uuid_mod
|
||||
from typing import Optional, Tuple, Dict
|
||||
from typing import Any, Optional, Tuple, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -13,10 +13,47 @@ from basic_memory.repository.search_repository import create_search_repository
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import (
|
||||
build_canonical_permalink,
|
||||
build_permalink_resolution_candidates,
|
||||
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 (
|
||||
_cloud_workspace_discovery_available,
|
||||
resolve_workspace_qualified_identifier,
|
||||
)
|
||||
|
||||
if not _cloud_workspace_discovery_available(config):
|
||||
return None
|
||||
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
)
|
||||
if workspace_resolution is None:
|
||||
return None
|
||||
return workspace_resolution.project_identifier
|
||||
|
||||
|
||||
class LinkResolver:
|
||||
@@ -189,25 +226,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
|
||||
)
|
||||
|
||||
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)
|
||||
# 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,
|
||||
)
|
||||
|
||||
# --- Path Resolution ---
|
||||
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import ast
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Set, Dict, Any
|
||||
|
||||
@@ -64,6 +65,22 @@ FTS_RELAXED_STOPWORDS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreparedSearchQuery:
|
||||
"""Normalized query inputs shared by search and count."""
|
||||
|
||||
search_text: str | None
|
||||
permalink: str | None
|
||||
permalink_match: str | None
|
||||
title: str | None
|
||||
note_types: list[str] | None
|
||||
search_item_types: list[SearchItemType] | None
|
||||
after_date: datetime | None
|
||||
metadata_filters: dict[str, Any] | None
|
||||
retrieval_mode: SearchRetrievalMode
|
||||
min_similarity: float | None
|
||||
|
||||
|
||||
def _strip_nul(value: str) -> str:
|
||||
"""Strip NUL bytes that PostgreSQL text columns cannot store.
|
||||
|
||||
@@ -132,27 +149,20 @@ class SearchService:
|
||||
|
||||
logger.info("Reindex complete")
|
||||
|
||||
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
def _prepare_query(self, query: SearchQuery) -> _PreparedSearchQuery | None:
|
||||
"""Normalize a SearchQuery into repository arguments."""
|
||||
search_text = query.text
|
||||
tags = query.tags
|
||||
|
||||
Supports three modes:
|
||||
1. Exact permalink: finds direct matches for a specific path
|
||||
2. Pattern match: handles * wildcards in paths
|
||||
3. Text search: full-text search across title/content
|
||||
"""
|
||||
# Support tag:<tag> shorthand by mapping to tags filter
|
||||
if query.text:
|
||||
text = query.text.strip()
|
||||
if text.lower().startswith("tag:"):
|
||||
tag_values = re.split(r"[,\s]+", text[4:].strip())
|
||||
tags = [t for t in tag_values if t]
|
||||
if tags:
|
||||
query.tags = tags
|
||||
query.text = None
|
||||
|
||||
if query.no_criteria():
|
||||
logger.debug("no criteria passed to query")
|
||||
return []
|
||||
# Support tag:<tag> shorthand by mapping to tags filter.
|
||||
if search_text is not None:
|
||||
search_text = search_text.strip() or None
|
||||
if search_text and search_text.lower().startswith("tag:"):
|
||||
tag_values = re.split(r"[,\s]+", search_text[4:].strip())
|
||||
parsed_tags = [t for t in tag_values if t]
|
||||
if parsed_tags:
|
||||
tags = parsed_tags
|
||||
search_text = None
|
||||
|
||||
after_date = (
|
||||
(
|
||||
@@ -164,50 +174,124 @@ class SearchService:
|
||||
else None
|
||||
)
|
||||
|
||||
# Merge structured metadata filters (explicit + convenience fields)
|
||||
# Merge structured metadata filters (explicit + convenience fields).
|
||||
metadata_filters: Optional[Dict[str, Any]] = None
|
||||
if query.metadata_filters or query.tags or query.status:
|
||||
if query.metadata_filters or tags or query.status:
|
||||
metadata_filters = dict(query.metadata_filters or {})
|
||||
if query.tags:
|
||||
metadata_filters.setdefault("tags", query.tags)
|
||||
if tags:
|
||||
metadata_filters.setdefault("tags", tags)
|
||||
if query.status:
|
||||
metadata_filters.setdefault("status", query.status)
|
||||
|
||||
retrieval_mode = query.retrieval_mode or SearchRetrievalMode.FTS
|
||||
strict_search_text = query.text
|
||||
prepared = _PreparedSearchQuery(
|
||||
search_text=search_text,
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
note_types=query.note_types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=query.retrieval_mode or SearchRetrievalMode.FTS,
|
||||
min_similarity=query.min_similarity,
|
||||
)
|
||||
|
||||
has_criteria = bool(
|
||||
prepared.search_text
|
||||
or prepared.permalink
|
||||
or prepared.permalink_match
|
||||
or prepared.title
|
||||
or prepared.note_types
|
||||
or prepared.search_item_types
|
||||
or prepared.after_date
|
||||
or prepared.metadata_filters
|
||||
)
|
||||
if not has_criteria:
|
||||
logger.debug("no criteria passed to query")
|
||||
return None
|
||||
return prepared
|
||||
|
||||
@staticmethod
|
||||
def _prepared_has_filters(prepared: _PreparedSearchQuery) -> bool:
|
||||
return bool(
|
||||
prepared.metadata_filters
|
||||
or prepared.note_types
|
||||
or prepared.search_item_types
|
||||
or prepared.after_date
|
||||
)
|
||||
|
||||
async def _search_repository(
|
||||
self,
|
||||
prepared: _PreparedSearchQuery,
|
||||
*,
|
||||
search_text: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> List[SearchIndexRow]:
|
||||
return await self.repository.search(
|
||||
search_text=search_text,
|
||||
permalink=prepared.permalink,
|
||||
permalink_match=prepared.permalink_match,
|
||||
title=prepared.title,
|
||||
note_types=prepared.note_types,
|
||||
search_item_types=prepared.search_item_types,
|
||||
after_date=prepared.after_date,
|
||||
metadata_filters=prepared.metadata_filters,
|
||||
retrieval_mode=prepared.retrieval_mode,
|
||||
min_similarity=prepared.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def _count_repository(
|
||||
self,
|
||||
prepared: _PreparedSearchQuery,
|
||||
*,
|
||||
search_text: str | None,
|
||||
) -> int:
|
||||
return await self.repository.count(
|
||||
search_text=search_text,
|
||||
permalink=prepared.permalink,
|
||||
permalink_match=prepared.permalink_match,
|
||||
title=prepared.title,
|
||||
note_types=prepared.note_types,
|
||||
search_item_types=prepared.search_item_types,
|
||||
after_date=prepared.after_date,
|
||||
metadata_filters=prepared.metadata_filters,
|
||||
retrieval_mode=prepared.retrieval_mode,
|
||||
min_similarity=prepared.min_similarity,
|
||||
)
|
||||
|
||||
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Supports three modes:
|
||||
1. Exact permalink: finds direct matches for a specific path
|
||||
2. Pattern match: handles * wildcards in paths
|
||||
3. Text search: full-text search across title/content
|
||||
"""
|
||||
prepared = self._prepare_query(query)
|
||||
if prepared is None:
|
||||
return []
|
||||
|
||||
strict_search_text = prepared.search_text
|
||||
has_query = bool(
|
||||
strict_search_text or query.title or query.permalink or query.permalink_match
|
||||
)
|
||||
has_filters = bool(
|
||||
metadata_filters
|
||||
or query.note_types
|
||||
or query.entity_types
|
||||
or after_date
|
||||
or query.tags
|
||||
or query.status
|
||||
strict_search_text or prepared.title or prepared.permalink or prepared.permalink_match
|
||||
)
|
||||
has_filters = self._prepared_has_filters(prepared)
|
||||
|
||||
with logfire.span(
|
||||
"search.execute",
|
||||
retrieval_mode=retrieval_mode.value,
|
||||
retrieval_mode=prepared.retrieval_mode.value,
|
||||
has_query=has_query,
|
||||
has_filters=has_filters,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
):
|
||||
logger.trace(f"Searching with query: {query}")
|
||||
# First pass: preserve existing strict search behavior.
|
||||
results = await self.repository.search(
|
||||
results = await self._search_repository(
|
||||
prepared,
|
||||
search_text=strict_search_text,
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
note_types=query.note_types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=query.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -217,7 +301,9 @@ class SearchService:
|
||||
# Outcome: retry once with relaxed OR terms while preserving explicit boolean intent.
|
||||
if results:
|
||||
return results
|
||||
if not self._is_relaxed_fts_fallback_eligible(query, strict_search_text, retrieval_mode):
|
||||
if not self._is_relaxed_fts_fallback_eligible(
|
||||
query, strict_search_text, prepared.retrieval_mode
|
||||
):
|
||||
return results
|
||||
|
||||
assert strict_search_text is not None
|
||||
@@ -231,26 +317,57 @@ class SearchService:
|
||||
)
|
||||
with logfire.span(
|
||||
"search.relaxed_fts_retry",
|
||||
retrieval_mode=retrieval_mode.value,
|
||||
retrieval_mode=prepared.retrieval_mode.value,
|
||||
token_count=len(self._tokenize_fts_text(strict_search_text)),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
):
|
||||
return await self.repository.search(
|
||||
return await self._search_repository(
|
||||
prepared,
|
||||
search_text=relaxed_search_text,
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
note_types=query.note_types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
retrieval_mode=retrieval_mode,
|
||||
min_similarity=query.min_similarity,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def count(self, query: SearchQuery) -> int:
|
||||
"""Count all indexed rows matching a query."""
|
||||
prepared = self._prepare_query(query)
|
||||
if prepared is None:
|
||||
return 0
|
||||
|
||||
strict_search_text = prepared.search_text
|
||||
has_query = bool(
|
||||
strict_search_text or prepared.title or prepared.permalink or prepared.permalink_match
|
||||
)
|
||||
has_filters = self._prepared_has_filters(prepared)
|
||||
|
||||
with logfire.span(
|
||||
"search.count",
|
||||
retrieval_mode=prepared.retrieval_mode.value,
|
||||
has_query=has_query,
|
||||
has_filters=has_filters,
|
||||
):
|
||||
total = await self._count_repository(prepared, search_text=strict_search_text)
|
||||
|
||||
if total > 0:
|
||||
return total
|
||||
if not self._is_relaxed_fts_fallback_eligible(
|
||||
query, strict_search_text, prepared.retrieval_mode
|
||||
):
|
||||
return total
|
||||
|
||||
assert strict_search_text is not None
|
||||
relaxed_search_text = self._build_relaxed_fts_query(strict_search_text)
|
||||
if relaxed_search_text == strict_search_text:
|
||||
return total
|
||||
|
||||
with logfire.span(
|
||||
"search.count.relaxed_fts_retry",
|
||||
retrieval_mode=prepared.retrieval_mode.value,
|
||||
token_count=len(self._tokenize_fts_text(strict_search_text)),
|
||||
):
|
||||
return await self._count_repository(prepared, search_text=relaxed_search_text)
|
||||
|
||||
@staticmethod
|
||||
def _tokenize_fts_text(search_text: str) -> list[str]:
|
||||
"""Tokenize text into alphanumeric terms for relaxed FTS fallback."""
|
||||
|
||||
@@ -1530,12 +1530,36 @@ class SyncService:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f'find "{directory}" -type f | wc -l',
|
||||
# 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",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
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""
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
@@ -1550,7 +1574,7 @@ class SyncService:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
return int(stdout.strip())
|
||||
return count
|
||||
|
||||
async def _scan_directory_modified_since(
|
||||
self, directory: Path, since_timestamp: float
|
||||
@@ -1583,8 +1607,16 @@ class SyncService:
|
||||
# Convert timestamp to find-compatible format
|
||||
since_date = datetime.fromtimestamp(since_timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f'find "{directory}" -type f -newermt "{since_date}"',
|
||||
# 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,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -93,6 +93,7 @@ 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`
|
||||
@@ -126,41 +127,54 @@ 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),
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
# process changes
|
||||
await asyncio.gather(*change_handlers)
|
||||
finally:
|
||||
self._sorted_watch_filter_roots = previous_filter_roots
|
||||
|
||||
async def _select_projects_to_watch(self) -> list[Project]:
|
||||
"""Return the set of projects this watch cycle should observe.
|
||||
@@ -267,15 +281,43 @@ class WatchService:
|
||||
self.state.running = False
|
||||
await self.write_status()
|
||||
|
||||
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
|
||||
def filter_changes(self, change: Change, path: str) -> bool:
|
||||
"""Filter to only watch non-hidden files and directories.
|
||||
|
||||
Returns:
|
||||
True if the file should be watched, False if it should be ignored
|
||||
"""
|
||||
|
||||
# Skip hidden directories and files
|
||||
path_parts = Path(path).parts
|
||||
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
|
||||
for part in path_parts:
|
||||
if part.startswith("."):
|
||||
return False
|
||||
|
||||
@@ -276,6 +276,137 @@ 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,
|
||||
|
||||
@@ -14,14 +14,14 @@ _WORKSPACE_TYPES = {"personal", "organization"}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePermalinkContext:
|
||||
"""Workspace metadata needed to build canonical organization permalinks."""
|
||||
"""Workspace metadata needed to build canonical workspace permalinks."""
|
||||
|
||||
workspace_slug: str
|
||||
workspace_type: str
|
||||
|
||||
@property
|
||||
def should_prefix_permalinks(self) -> bool:
|
||||
return self.workspace_type == "organization" and bool(self.workspace_slug)
|
||||
return bool(self.workspace_slug)
|
||||
|
||||
|
||||
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(
|
||||
|
||||
@@ -138,6 +138,17 @@ 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,34 +1,30 @@
|
||||
"""
|
||||
Integration test for long relation_type values (regression guard for issue #721).
|
||||
"""Integration test for long prose before inline wikilinks.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
1 validation error for EntityResponseV2
|
||||
relations.0.relation_type
|
||||
String should have at most 200 characters
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
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):
|
||||
"""edit_note must succeed on notes whose inline wikilinks have >200 chars
|
||||
of prose preceding them — that prose becomes the parsed relation_type."""
|
||||
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."""
|
||||
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, "
|
||||
@@ -75,3 +71,11 @@ async def test_edit_note_handles_long_prose_around_wikilink(mcp_server, app, tes
|
||||
)
|
||||
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,6 +28,7 @@ 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"]',
|
||||
},
|
||||
)
|
||||
@@ -54,6 +55,7 @@ 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"]',
|
||||
},
|
||||
)
|
||||
@@ -81,6 +83,7 @@ async def test_search_notes_tags_as_string(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "tagged",
|
||||
"search_type": "text",
|
||||
"tags": '["alpha"]',
|
||||
},
|
||||
)
|
||||
@@ -107,6 +110,7 @@ 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"}',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""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
|
||||
@@ -482,6 +482,23 @@ 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."""
|
||||
|
||||
@@ -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]]
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,25 @@ 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) ---
|
||||
|
||||
@@ -198,3 +217,63 @@ 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"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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")
|
||||
@@ -321,6 +321,21 @@ 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."""
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_search_entities(
|
||||
)
|
||||
|
||||
# Search for the entity
|
||||
response = await client.post(f"{v2_project_url}/search/", json={"search_text": "Searchable"})
|
||||
response = await client.post(f"{v2_project_url}/search/", json={"text": "Searchable"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -94,7 +94,7 @@ async def test_search_with_pagination(
|
||||
# Search with pagination
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "Search Entity"},
|
||||
json={"text": "Search Entity"},
|
||||
params={"page": 1, "page_size": 3},
|
||||
)
|
||||
|
||||
@@ -102,6 +102,57 @@ async def test_search_with_pagination(
|
||||
data = response.json()
|
||||
assert data["current_page"] == 1
|
||||
assert data["page_size"] == 3
|
||||
assert data["total"] == 5
|
||||
assert data["has_more"] is True
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"text": "Search Entity"},
|
||||
params={"page": 2, "page_size": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["current_page"] == 2
|
||||
assert data["page_size"] == 3
|
||||
assert data["total"] == 5
|
||||
assert data["has_more"] is False
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_item_type_filter_returns_total(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Metadata-only graph searches should include exact totals for pagination."""
|
||||
for i in range(5):
|
||||
entity_data = {
|
||||
"title": f"Structured Entity {i}",
|
||||
"note_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": f"structured_{i}.md",
|
||||
"checksum": f"structuredsum{i}",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"entity_types": ["entity"]},
|
||||
params={"page": 1, "page_size": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 5
|
||||
assert data["has_more"] is True
|
||||
assert len(data["results"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -192,7 +243,7 @@ async def test_search_with_type_filter(
|
||||
|
||||
# Search with type filter
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/", json={"search_text": "Type", "note_types": ["note"]}
|
||||
f"{v2_project_url}/search/", json={"text": "Type", "note_types": ["note"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -225,7 +276,7 @@ async def test_search_with_date_filter(
|
||||
# Search with date filter
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"},
|
||||
json={"text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -246,12 +297,42 @@ async def test_search_empty_query(
|
||||
assert response.status_code in [200, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_whitespace_text_is_treated_as_empty(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Whitespace-only text should not become an unfiltered project-wide search."""
|
||||
entity_data = {
|
||||
"title": "Whitespace Regression Entity",
|
||||
"note_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "whitespace_regression.md",
|
||||
"checksum": "whitespace123",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
response = await client.post(f"{v2_project_url}/search/", json={"text": " "})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["has_more"] is False
|
||||
assert data["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test searching with invalid project ID returns 404."""
|
||||
response = await client.post("/v2/projects/999999/search/", json={"search_text": "test"})
|
||||
response = await client.post("/v2/projects/999999/search/", json={"text": "test"})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -291,7 +372,7 @@ async def test_v2_search_endpoints_use_project_id_not_name(
|
||||
):
|
||||
"""Test that v2 search endpoints reject string project names."""
|
||||
# Try to use project name instead of ID - should fail
|
||||
response = await client.post(f"/v2/{test_project.name}/search/", json={"search_text": "test"})
|
||||
response = await client.post(f"/v2/{test_project.name}/search/", json={"text": "test"})
|
||||
|
||||
# FastAPI path validation should reject non-integer project_id
|
||||
assert response.status_code in [404, 422]
|
||||
@@ -307,11 +388,14 @@ async def test_search_router_returns_400_for_semantic_disabled(
|
||||
async def search(self, *args, **kwargs):
|
||||
raise SemanticSearchDisabledError("Semantic search is disabled for this project.")
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
raise SemanticSearchDisabledError("Semantic search is disabled for this project.")
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "semantic query", "retrieval_mode": "vector"},
|
||||
json={"text": "semantic query", "retrieval_mode": "vector"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_search_service_v2_external, None)
|
||||
@@ -330,11 +414,14 @@ async def test_search_router_returns_400_for_semantic_missing_deps(
|
||||
async def search(self, *args, **kwargs):
|
||||
raise SemanticDependenciesMissingError("Semantic dependencies are missing.")
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
raise SemanticDependenciesMissingError("Semantic dependencies are missing.")
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "semantic query", "retrieval_mode": "hybrid"},
|
||||
json={"text": "semantic query", "retrieval_mode": "hybrid"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_search_service_v2_external, None)
|
||||
@@ -353,6 +440,9 @@ async def test_search_router_returns_400_for_invalid_vector_query(
|
||||
async def search(self, *args, **kwargs):
|
||||
raise ValueError("Vector retrieval requires a text query.")
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
raise ValueError("Vector retrieval requires a text query.")
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
@@ -366,6 +456,56 @@ async def test_search_router_returns_400_for_invalid_vector_query(
|
||||
assert "Vector retrieval requires a text query" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_search_uses_probe_pagination_without_count(
|
||||
client: AsyncClient,
|
||||
app,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Semantic searches should not run an extra count query."""
|
||||
now = datetime.now(timezone.utc)
|
||||
fake_rows = [
|
||||
SearchIndexRow(
|
||||
project_id=1,
|
||||
id=row_id,
|
||||
type="entity",
|
||||
file_path=f"notes/semantic-{row_id}.md",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
title=f"Semantic Result {row_id}",
|
||||
permalink=f"notes/semantic-{row_id}",
|
||||
score=1.0 - (row_id / 10),
|
||||
)
|
||||
for row_id in range(1, 4)
|
||||
]
|
||||
|
||||
class FakeSearchService:
|
||||
async def search(self, query, *, limit, offset):
|
||||
assert query.retrieval_mode.value == "vector"
|
||||
assert limit == 3
|
||||
assert offset == 0
|
||||
return fake_rows
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
raise AssertionError("semantic search must not run count")
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: FakeSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"text": "semantic query", "retrieval_mode": "vector"},
|
||||
params={"page": 1, "page_size": 2},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_search_service_v2_external, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["has_more"] is True
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_has_more_when_more_results_exist(
|
||||
client: AsyncClient,
|
||||
@@ -459,11 +599,14 @@ async def test_search_result_includes_matched_chunk(
|
||||
async def search(self, *args, **kwargs):
|
||||
return [fake_row]
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
return 1
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: FakeSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "pricing"},
|
||||
json={"text": "pricing"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_search_service_v2_external, None)
|
||||
@@ -500,11 +643,14 @@ async def test_search_result_omits_matched_chunk_when_none(
|
||||
async def search(self, *args, **kwargs):
|
||||
return [fake_row]
|
||||
|
||||
async def count(self, *args, **kwargs):
|
||||
return 1
|
||||
|
||||
app.dependency_overrides[get_search_service_v2_external] = lambda: FakeSearchService()
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "general"},
|
||||
json={"text": "general"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_search_service_v2_external, None)
|
||||
|
||||
@@ -23,6 +23,9 @@ async def test_search_router_wraps_request_in_manual_operation(monkeypatch) -> N
|
||||
async def search(self, query, *, limit, offset):
|
||||
return []
|
||||
|
||||
async def count(self, query):
|
||||
return 0
|
||||
|
||||
@contextmanager
|
||||
def fake_span(name: str, **attrs):
|
||||
operations.append((name, attrs))
|
||||
|
||||
@@ -32,13 +32,48 @@ def test_convert_bmignore_to_rclone_filters_creates_and_converts(config_home):
|
||||
# Comments/empties preserved
|
||||
assert "# comment" in content
|
||||
assert "" in content
|
||||
# Directory pattern becomes recursive exclude
|
||||
# Plain and wildcard patterns exclude direct matches and recursive contents.
|
||||
assert "- node_modules" in content
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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)
|
||||
@@ -80,3 +80,26 @@ 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()}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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"}
|
||||
@@ -10,6 +10,7 @@ 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.
|
||||
@@ -53,6 +54,26 @@ 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
|
||||
):
|
||||
@@ -215,6 +236,680 @@ 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
|
||||
):
|
||||
|
||||
@@ -182,6 +182,46 @@ 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,3 +128,51 @@ 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
|
||||
|
||||
@@ -133,6 +133,39 @@ 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."""
|
||||
|
||||
@@ -340,6 +340,106 @@ async def test_workspace_invalid_selection_lists_choices(monkeypatch):
|
||||
await resolve_workspace_parameter(workspace="missing-workspace")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_ambiguous_type_selection_lists_matching_choices(monkeypatch):
|
||||
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
||||
|
||||
workspaces = [
|
||||
_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",
|
||||
),
|
||||
]
|
||||
|
||||
async def fake_get_available_workspaces(context=None):
|
||||
return workspaces
|
||||
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.mcp.project_context.get_available_workspaces",
|
||||
fake_get_available_workspaces,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await resolve_workspace_parameter(workspace="organization")
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Workspace 'organization' matches multiple workspaces" in message
|
||||
assert "workspace: team-alpha" in message
|
||||
assert "workspace: team-beta" in message
|
||||
assert "workspace: personal" not in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_type_selection_ignores_cached_workspace_for_ambiguity(monkeypatch):
|
||||
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
||||
|
||||
cached_workspace = _workspace(
|
||||
tenant_id="22222222-2222-2222-2222-222222222222",
|
||||
workspace_type="organization",
|
||||
slug="team-alpha",
|
||||
name="Team Alpha",
|
||||
role="editor",
|
||||
)
|
||||
workspaces = [
|
||||
_workspace(
|
||||
tenant_id="11111111-1111-1111-1111-111111111111",
|
||||
workspace_type="personal",
|
||||
slug="personal",
|
||||
name="Personal",
|
||||
role="owner",
|
||||
is_default=True,
|
||||
),
|
||||
cached_workspace,
|
||||
_workspace(
|
||||
tenant_id="33333333-3333-3333-3333-333333333333",
|
||||
workspace_type="organization",
|
||||
slug="team-beta",
|
||||
name="Team Beta",
|
||||
role="owner",
|
||||
),
|
||||
]
|
||||
context = _ContextState()
|
||||
await context.set_state("active_workspace", cached_workspace.model_dump())
|
||||
fetches = 0
|
||||
|
||||
async def fake_get_available_workspaces(context=None):
|
||||
nonlocal fetches
|
||||
fetches += 1
|
||||
return workspaces
|
||||
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.mcp.project_context.get_available_workspaces",
|
||||
fake_get_available_workspaces,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await resolve_workspace_parameter(workspace="organization", context=_ctx(context))
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert fetches == 1
|
||||
assert "Workspace 'organization' matches multiple workspaces" in message
|
||||
assert "workspace: team-alpha" in message
|
||||
assert "workspace: team-beta" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
|
||||
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
||||
@@ -826,7 +926,7 @@ async def test_resolve_workspace_qualified_memory_url_uses_personal_canonical_pa
|
||||
resolved = await resolve_workspace_qualified_memory_url("memory://personal/main/notes/foo")
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.canonical_path == "main/notes/foo"
|
||||
assert resolved.canonical_path == "personal/main/notes/foo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1373,7 +1473,7 @@ async def test_resolve_project_and_path_keeps_workspace_qualified_canonical_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_and_path_strips_personal_workspace_prefix(
|
||||
async def test_resolve_project_and_path_preserves_personal_workspace_prefix(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
@@ -1417,7 +1517,7 @@ async def test_resolve_project_and_path_strips_personal_workspace_prefix(
|
||||
)
|
||||
|
||||
assert active_project == cached_project
|
||||
assert resolved_path == "main/notes/foo"
|
||||
assert resolved_path == "personal/main/notes/foo"
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
@@ -1453,6 +1553,225 @@ async def test_resolve_project_and_path_preserves_existing_project_prefixed_memo
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_and_path_uses_cached_workspace_for_active_route(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
import basic_memory.mcp.project_context as project_context
|
||||
from basic_memory.mcp.project_context import resolve_project_and_path
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
|
||||
config = config_manager.load_config()
|
||||
config.permalinks_include_project = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
context = _ContextState()
|
||||
cached_project = ProjectItem(
|
||||
id=1,
|
||||
external_id="11111111-1111-1111-1111-111111111111",
|
||||
name="main",
|
||||
path="/tmp/main",
|
||||
is_default=False,
|
||||
)
|
||||
team_workspace = _workspace(
|
||||
tenant_id="team-tenant",
|
||||
workspace_type="organization",
|
||||
slug="team-paul",
|
||||
name="Team Paul",
|
||||
role="editor",
|
||||
)
|
||||
await context.set_state("active_project", cached_project.model_dump())
|
||||
await context.set_state("active_workspace", team_workspace.model_dump())
|
||||
|
||||
async def fake_call_post(*args, **kwargs):
|
||||
raise ToolError("project not found")
|
||||
|
||||
async def fake_get_active_project(*args, **kwargs):
|
||||
return cached_project
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
|
||||
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
|
||||
|
||||
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, None),
|
||||
identifier="memory://notes/foo",
|
||||
project="main",
|
||||
context=_ctx(context),
|
||||
)
|
||||
|
||||
assert active_project == cached_project
|
||||
assert resolved_path == "team-paul/main/notes/foo"
|
||||
assert is_memory_url is True
|
||||
|
||||
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, None),
|
||||
identifier="memory://main",
|
||||
project="main",
|
||||
context=_ctx(context),
|
||||
)
|
||||
|
||||
assert active_project == cached_project
|
||||
assert resolved_path == "team-paul/main"
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_and_path_uses_workspace_context_for_project_root(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
import basic_memory.mcp.project_context as project_context
|
||||
from basic_memory.mcp.project_context import resolve_project_and_path
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from basic_memory.workspace_context import workspace_permalink_context
|
||||
|
||||
config = config_manager.load_config()
|
||||
config.permalinks_include_project = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
active = ProjectItem(
|
||||
id=1,
|
||||
external_id="11111111-1111-1111-1111-111111111111",
|
||||
name="main",
|
||||
path="/tmp/main",
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
async def fake_get_active_project(*args, **kwargs):
|
||||
return active
|
||||
|
||||
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
|
||||
|
||||
with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"):
|
||||
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, None),
|
||||
identifier="memory://main",
|
||||
project="main",
|
||||
context=_ctx(_ContextState()),
|
||||
)
|
||||
|
||||
assert active_project == active
|
||||
assert resolved_path == "team-paul/main"
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_and_path_uses_cached_workspace_for_cached_project_prefix(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
import basic_memory.mcp.project_context as project_context
|
||||
from basic_memory.mcp.project_context import resolve_project_and_path
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
|
||||
config = config_manager.load_config()
|
||||
config.permalinks_include_project = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
context = _ContextState()
|
||||
cached_project = ProjectItem(
|
||||
id=1,
|
||||
external_id="11111111-1111-1111-1111-111111111111",
|
||||
name="main",
|
||||
path="/tmp/main",
|
||||
is_default=False,
|
||||
)
|
||||
team_workspace = _workspace(
|
||||
tenant_id="team-tenant",
|
||||
workspace_type="organization",
|
||||
slug="team-paul",
|
||||
name="Team Paul",
|
||||
role="editor",
|
||||
)
|
||||
await context.set_state("active_project", cached_project.model_dump())
|
||||
await context.set_state("active_workspace", team_workspace.model_dump())
|
||||
|
||||
async def fail_call_post(*args, **kwargs): # pragma: no cover
|
||||
raise AssertionError("Cached project prefix should not call project resolve API")
|
||||
|
||||
async def fake_resolve_project_parameter(project=None, **kwargs):
|
||||
return cached_project.name if project else cached_project.name
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fail_call_post)
|
||||
monkeypatch.setattr(
|
||||
project_context,
|
||||
"resolve_project_parameter",
|
||||
fake_resolve_project_parameter,
|
||||
)
|
||||
|
||||
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, None),
|
||||
identifier="memory://main/notes/foo",
|
||||
context=_ctx(context),
|
||||
)
|
||||
|
||||
assert active_project == cached_project
|
||||
assert resolved_path == "team-paul/main/notes/foo"
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_and_path_uses_cached_workspace_for_resolved_project_prefix(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
import basic_memory.mcp.project_context as project_context
|
||||
from basic_memory.mcp.project_context import resolve_project_and_path
|
||||
|
||||
config = config_manager.load_config()
|
||||
config.permalinks_include_project = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
context = _ContextState()
|
||||
team_workspace = _workspace(
|
||||
tenant_id="team-tenant",
|
||||
workspace_type="organization",
|
||||
slug="team-paul",
|
||||
name="Team Paul",
|
||||
role="editor",
|
||||
)
|
||||
await context.set_state("active_workspace", team_workspace.model_dump())
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {
|
||||
"external_id": "22222222-2222-2222-2222-222222222222",
|
||||
"project_id": 2,
|
||||
"name": "Research",
|
||||
"permalink": "research",
|
||||
"path": "/tmp/research",
|
||||
"is_active": True,
|
||||
"is_default": False,
|
||||
"resolution_method": "permalink",
|
||||
}
|
||||
|
||||
async def fake_call_post(*args, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
async def fake_resolve_project_parameter(project=None, **kwargs):
|
||||
return "Research" if project else "Research"
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
|
||||
monkeypatch.setattr(
|
||||
project_context,
|
||||
"resolve_project_parameter",
|
||||
fake_resolve_project_parameter,
|
||||
)
|
||||
|
||||
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, None),
|
||||
identifier="memory://research/notes/foo",
|
||||
context=_ctx(context),
|
||||
)
|
||||
|
||||
assert active_project.name == "Research"
|
||||
assert resolved_path == "team-paul/research/notes/foo"
|
||||
assert is_memory_url is True
|
||||
|
||||
|
||||
class TestDetectProjectFromUrlPrefix:
|
||||
"""Test detect_project_from_url_prefix for URL-based project detection."""
|
||||
|
||||
@@ -1556,6 +1875,92 @@ class TestGetProjectClientRoutingOrder:
|
||||
# The error should NOT be about workspaces
|
||||
assert "workspace" not in str(exc_info.value).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_route_clears_stale_cached_workspace(self, config_manager, monkeypatch):
|
||||
"""A previous cloud workspace must not decorate later local memory URLs."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
import basic_memory.mcp.project_context as project_context
|
||||
from basic_memory.config import ProjectEntry
|
||||
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from basic_memory.workspace_context import current_workspace_permalink_context
|
||||
|
||||
config = config_manager.load_config()
|
||||
config.permalinks_include_project = True
|
||||
config.projects["local-proj"] = ProjectEntry(
|
||||
path=str(config_manager.config_dir.parent / "local-proj")
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
context = _ContextState()
|
||||
stale_workspace = _workspace(
|
||||
tenant_id="team-tenant",
|
||||
workspace_type="organization",
|
||||
slug="team-paul",
|
||||
name="Team Paul",
|
||||
role="editor",
|
||||
)
|
||||
await context.set_state("active_workspace", stale_workspace.model_dump())
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
active = ProjectItem(
|
||||
id=1,
|
||||
external_id="local-project-id",
|
||||
name="local-proj",
|
||||
path="/local-proj",
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
async def fail_ensure_workspace_project_index(context=None): # pragma: no cover
|
||||
raise AssertionError("Local routing must not discover cloud workspaces")
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_client(project_name=None, workspace=None):
|
||||
seen["project_name"] = project_name
|
||||
seen["workspace"] = workspace
|
||||
seen["permalink_context"] = current_workspace_permalink_context()
|
||||
yield object()
|
||||
|
||||
async def fake_get_active_project(client, project_name, context=None, headers=None):
|
||||
assert project_name == "local-proj"
|
||||
return active
|
||||
|
||||
async def fake_call_post(*args, **kwargs):
|
||||
raise ToolError("project not found")
|
||||
|
||||
monkeypatch.setattr(
|
||||
project_context,
|
||||
"_ensure_workspace_project_index",
|
||||
fail_ensure_workspace_project_index,
|
||||
)
|
||||
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
|
||||
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
|
||||
|
||||
async with get_project_client(
|
||||
project="local-proj",
|
||||
context=_ctx(context),
|
||||
) as (client, active_project):
|
||||
_, resolved_path, is_memory_url = await resolve_project_and_path(
|
||||
client=cast(Any, client),
|
||||
identifier="memory://notes/foo",
|
||||
project="local-proj",
|
||||
context=_ctx(context),
|
||||
)
|
||||
|
||||
assert active_project == active
|
||||
assert seen == {
|
||||
"project_name": "local-proj",
|
||||
"workspace": None,
|
||||
"permalink_context": None,
|
||||
}
|
||||
assert resolved_path == "local-proj/notes/foo"
|
||||
assert is_memory_url is True
|
||||
assert await context.get_state("active_workspace") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_project_uses_per_project_workspace_id(self, config_manager, monkeypatch):
|
||||
"""Cloud project with workspace_id uses cached workspace permalink context."""
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools import build_context
|
||||
from basic_memory.mcp.tools import build_context, write_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -75,6 +75,36 @@ 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."""
|
||||
|
||||
@@ -31,12 +31,13 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"output_format",
|
||||
],
|
||||
"delete_note": ["identifier", "is_directory", "project", "project_id", "output_format"],
|
||||
"delete_project": ["project_name"],
|
||||
"delete_project": ["project_name", "workspace"],
|
||||
"edit_note": [
|
||||
"identifier",
|
||||
"operation",
|
||||
"content",
|
||||
"project",
|
||||
"workspace",
|
||||
"project_id",
|
||||
"section",
|
||||
"find_text",
|
||||
@@ -83,6 +84,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"query",
|
||||
"project",
|
||||
"project_id",
|
||||
"search_all_projects",
|
||||
"page",
|
||||
"page_size",
|
||||
"search_type",
|
||||
|
||||
@@ -9,6 +9,68 @@ 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."""
|
||||
@@ -827,6 +889,221 @@ 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,
|
||||
"_cloud_workspace_discovery_available",
|
||||
lambda 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,
|
||||
"_cloud_workspace_discovery_available",
|
||||
lambda 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,
|
||||
"_cloud_workspace_discovery_available",
|
||||
lambda 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_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,
|
||||
"_cloud_workspace_discovery_available",
|
||||
lambda 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.
|
||||
@@ -834,9 +1111,12 @@ 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_detect:
|
||||
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,
|
||||
):
|
||||
# Use a plain path (no memory:// prefix) — detection should not be called
|
||||
await edit_note(
|
||||
identifier="test/some-note",
|
||||
@@ -845,15 +1125,19 @@ async def test_edit_note_skips_detection_for_plain_path(client, test_project):
|
||||
project=None,
|
||||
)
|
||||
|
||||
mock_detect.assert_not_called()
|
||||
mock_url.assert_not_called()
|
||||
mock_workspace.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_detect:
|
||||
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,
|
||||
):
|
||||
await edit_note(
|
||||
identifier=f"memory://{test_project.name}/test/some-note",
|
||||
operation="append",
|
||||
@@ -861,7 +1145,8 @@ async def test_edit_note_skips_detection_when_project_provided(client, test_proj
|
||||
project=test_project.name,
|
||||
)
|
||||
|
||||
mock_detect.assert_not_called()
|
||||
mock_url.assert_not_called()
|
||||
mock_workspace.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -885,6 +1170,11 @@ 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",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for MCP project management tools."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -136,9 +138,6 @@ 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
|
||||
|
||||
@@ -207,9 +206,6 @@ 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
|
||||
|
||||
@@ -276,9 +272,6 @@ 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
|
||||
|
||||
@@ -328,6 +321,248 @@ 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 ---
|
||||
|
||||
|
||||
|
||||
@@ -292,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_memory_url_prefix", fail_if_called)
|
||||
monkeypatch.setattr(read_note_module, "detect_project_from_identifier_prefix", fail_if_called)
|
||||
|
||||
result = await read_note(
|
||||
f"memory://{test_project.name}/test/project-id-memory-url-read",
|
||||
@@ -380,14 +380,14 @@ async def test_team_workspace_write_stores_complete_permalink_when_project_prefi
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personal_workspace_write_keeps_project_scoped_permalink(
|
||||
async def test_personal_workspace_write_stores_complete_canonical_permalink(
|
||||
app,
|
||||
test_project,
|
||||
entity_repository,
|
||||
):
|
||||
from basic_memory.workspace_context import workspace_permalink_context
|
||||
|
||||
expected_permalink = f"{test_project.name}/personal/personal-workspace-note"
|
||||
expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note"
|
||||
|
||||
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
|
||||
write_result = await write_note(
|
||||
@@ -404,6 +404,68 @@ async def test_personal_workspace_write_keeps_project_scoped_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,
|
||||
|
||||
@@ -116,6 +116,71 @@ 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."""
|
||||
|
||||
@@ -28,7 +28,10 @@ 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", output_format="json"
|
||||
project=test_project.name,
|
||||
query="searchable",
|
||||
search_type="text",
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
# Verify results - handle both success and error cases
|
||||
@@ -183,7 +186,12 @@ 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", page=1, page_size=1, output_format="json"
|
||||
project=test_project.name,
|
||||
query="searchable",
|
||||
search_type="text",
|
||||
page=1,
|
||||
page_size=1,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
# Verify results - handle both success and error cases
|
||||
@@ -212,7 +220,11 @@ 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", note_types=["note"], output_format="json"
|
||||
project=test_project.name,
|
||||
query="type",
|
||||
search_type="text",
|
||||
note_types=["note"],
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
# Verify results - handle both success and error cases
|
||||
@@ -237,7 +249,11 @@ 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", entity_types=["entity"], output_format="json"
|
||||
project=test_project.name,
|
||||
query="type",
|
||||
search_type="text",
|
||||
entity_types=["entity"],
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
# Verify results - handle both success and error cases
|
||||
@@ -265,6 +281,7 @@ 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",
|
||||
)
|
||||
|
||||
@@ -157,6 +157,7 @@ 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,
|
||||
|
||||
@@ -13,6 +13,11 @@ 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
|
||||
@@ -170,6 +175,26 @@ 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."""
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
|
||||
@@ -309,7 +310,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):
|
||||
async def test_write_note_verbose(app, test_project, engine_factory):
|
||||
"""Test creating a new note.
|
||||
|
||||
Should:
|
||||
@@ -326,7 +327,7 @@ async def test_write_note_verbose(app, test_project):
|
||||
# Test\nThis is a test note
|
||||
|
||||
- [note] First observation
|
||||
- relates to [[Knowledge]]
|
||||
- "relates to" [[Knowledge]]
|
||||
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
@@ -343,6 +344,11 @@ async def test_write_note_verbose(app, test_project):
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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
|
||||
@@ -85,6 +85,35 @@ 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."""
|
||||
@@ -127,6 +156,27 @@ 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
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Tests for optional multi-project search_notes behavior."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult
|
||||
|
||||
|
||||
def _stub_routing_mode(monkeypatch, *, cloud: bool) -> None:
|
||||
"""Pin the three cloud-route signals search.py reads.
|
||||
|
||||
`_search_all_projects` only forwards project_id (external UUID) when a
|
||||
cloud route is available. The composite mirrors get_project_client:
|
||||
factory mode OR explicit --cloud OR has_cloud_credentials. Tests stub
|
||||
all three so a dev box with OAuth tokens on disk can't bleed into the
|
||||
local-mode case.
|
||||
"""
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
monkeypatch.setattr(search_mod, "is_factory_mode", lambda: False)
|
||||
monkeypatch.setattr(search_mod, "_explicit_routing", lambda: cloud)
|
||||
monkeypatch.setattr(search_mod, "_force_local_mode", lambda: False)
|
||||
monkeypatch.setattr(search_mod, "has_cloud_credentials", lambda config: cloud)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloud_routing(monkeypatch):
|
||||
"""Force the cloud-routing path for multi-project search tests."""
|
||||
_stub_routing_mode(monkeypatch, cloud=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_routing(monkeypatch):
|
||||
"""Force the local-routing path for multi-project search tests."""
|
||||
_stub_routing_mode(monkeypatch, cloud=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_search_all_projects_qualifies_result_permalinks(
|
||||
monkeypatch, cloud_routing
|
||||
):
|
||||
"""Multi-project search belongs to search_notes and keeps result ids routable."""
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
|
||||
project_refs = [
|
||||
{
|
||||
"project": "personal/main",
|
||||
"project_id": "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
{
|
||||
"project": "team-paul/main",
|
||||
"project_id": "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
]
|
||||
searched_projects: list[tuple[str | None, str | None]] = []
|
||||
|
||||
async def fake_load_search_project_refs(context=None):
|
||||
return project_refs
|
||||
|
||||
class StubProject:
|
||||
def __init__(self, name: str | None, external_id: str | None):
|
||||
self.name = name or "main"
|
||||
self.external_id = external_id or "local-main"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_project_client(project=None, context=None, project_id=None):
|
||||
searched_projects.append((project, project_id))
|
||||
yield object(), StubProject(project, project_id)
|
||||
|
||||
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
|
||||
return StubProject(project, None), identifier, False
|
||||
|
||||
class MockSearchClient:
|
||||
def __init__(self, client, project_id):
|
||||
self.project_id = project_id
|
||||
|
||||
async def search(self, payload, page, page_size):
|
||||
if self.project_id == "11111111-1111-1111-1111-111111111111":
|
||||
title = "Personal MCP Test Note"
|
||||
score = 0.5
|
||||
else:
|
||||
title = "Team MCP Test Note"
|
||||
score = 0.9
|
||||
return SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
title=title,
|
||||
permalink="main/tests/mcp-test-note",
|
||||
content="MCP content",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=score,
|
||||
file_path="/main/tests/mcp-test-note.md",
|
||||
)
|
||||
],
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
total=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
|
||||
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", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes(
|
||||
query="MCP Test Note",
|
||||
search_all_projects=True,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert searched_projects == [
|
||||
("personal/main", "11111111-1111-1111-1111-111111111111"),
|
||||
("team-paul/main", "22222222-2222-2222-2222-222222222222"),
|
||||
]
|
||||
assert [item["permalink"] for item in result["results"]] == [
|
||||
"team-paul/main/tests/mcp-test-note",
|
||||
"personal/main/tests/mcp-test-note",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_multi_project_search_is_opt_in(monkeypatch):
|
||||
"""Default search_notes calls stay scoped to the resolved project."""
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
|
||||
searched_projects: list[tuple[str | None, str | None]] = []
|
||||
|
||||
async def fake_load_search_project_refs(context=None):
|
||||
raise AssertionError("project discovery should only run when search_all_projects=True")
|
||||
|
||||
class StubProject:
|
||||
name = "main"
|
||||
external_id = "local-main"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_project_client(project=None, context=None, project_id=None):
|
||||
searched_projects.append((project, project_id))
|
||||
yield object(), StubProject()
|
||||
|
||||
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
|
||||
return StubProject(), identifier, False
|
||||
|
||||
class MockSearchClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def search(self, payload, page, page_size):
|
||||
return SearchResponse(results=[], current_page=page, page_size=page_size)
|
||||
|
||||
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
|
||||
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", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes(query="MCP Test Note", output_format="json")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["results"] == []
|
||||
assert searched_projects == [(None, None)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_search_all_projects_with_no_refs_returns_empty_all_projects(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Explicit all-project search must not silently fall back to one project."""
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
|
||||
async def fake_load_search_project_refs(context=None):
|
||||
return []
|
||||
|
||||
@asynccontextmanager
|
||||
async def fail_get_project_client(*args, **kwargs):
|
||||
raise AssertionError("search_all_projects=True should not fall back to scoped search")
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
|
||||
monkeypatch.setattr(search_mod, "get_project_client", fail_get_project_client)
|
||||
|
||||
result = await search_mod.search_notes(
|
||||
query="MCP Test Note",
|
||||
search_all_projects=True,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result == {
|
||||
"results": [],
|
||||
"current_page": 1,
|
||||
"page_size": 10,
|
||||
"total": 0,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_search_all_projects_continues_after_project_failure(
|
||||
monkeypatch, cloud_routing
|
||||
):
|
||||
"""One failing project should not discard successful all-project search results."""
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
|
||||
project_refs = [
|
||||
{
|
||||
"project": "personal/main",
|
||||
"project_id": "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
{
|
||||
"project": "team-paul/main",
|
||||
"project_id": "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
]
|
||||
warnings: list[str] = []
|
||||
|
||||
async def fake_load_search_project_refs(context=None):
|
||||
return project_refs
|
||||
|
||||
class StubProject:
|
||||
def __init__(self, name: str | None, external_id: str | None):
|
||||
self.name = name or "main"
|
||||
self.external_id = external_id or "local-main"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_project_client(project=None, context=None, project_id=None):
|
||||
yield object(), StubProject(project, project_id)
|
||||
|
||||
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
|
||||
return StubProject(project, None), identifier, False
|
||||
|
||||
class FakeLogger:
|
||||
def debug(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def error(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def warning(self, message, *args, **kwargs):
|
||||
warnings.append(str(message))
|
||||
|
||||
class MockSearchClient:
|
||||
def __init__(self, client, project_id):
|
||||
self.project_id = project_id
|
||||
|
||||
async def search(self, payload, page, page_size):
|
||||
if self.project_id == "22222222-2222-2222-2222-222222222222":
|
||||
raise RuntimeError("team index unavailable")
|
||||
return SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
title="Personal MCP Test Note",
|
||||
permalink="main/tests/mcp-test-note",
|
||||
content="MCP content",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=0.5,
|
||||
file_path="/main/tests/mcp-test-note.md",
|
||||
)
|
||||
],
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
total=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
|
||||
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(search_mod, "logger", FakeLogger())
|
||||
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes(
|
||||
query="MCP Test Note",
|
||||
search_all_projects=True,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert [item["permalink"] for item in result["results"]] == [
|
||||
"personal/main/tests/mcp-test-note",
|
||||
]
|
||||
assert result["total"] == 1
|
||||
assert any("team-paul/main" in warning for warning in warnings)
|
||||
assert any("team index unavailable" in warning for warning in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_search_all_projects_local_omits_project_id(
|
||||
monkeypatch, local_routing
|
||||
):
|
||||
"""Without a cloud route, fan-out must address each project by name only.
|
||||
|
||||
project_id (external UUID) routes through the cloud v2 API path, which
|
||||
returns 401 on local installs because there's no JWT to present. Local
|
||||
fan-out has to fall back to the name-routed path so each per-project
|
||||
search actually returns results instead of silently failing.
|
||||
"""
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
|
||||
project_refs = [
|
||||
{
|
||||
"project": "alpha",
|
||||
"project_id": "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
{
|
||||
"project": "beta",
|
||||
"project_id": "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
]
|
||||
searched_projects: list[tuple[str | None, str | None]] = []
|
||||
|
||||
async def fake_load_search_project_refs(context=None):
|
||||
return project_refs
|
||||
|
||||
class StubProject:
|
||||
def __init__(self, name: str | None, external_id: str | None):
|
||||
self.name = name or "main"
|
||||
self.external_id = external_id or "local-main"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_project_client(project=None, context=None, project_id=None):
|
||||
searched_projects.append((project, project_id))
|
||||
yield object(), StubProject(project, project_id)
|
||||
|
||||
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
|
||||
return StubProject(project, None), identifier, False
|
||||
|
||||
class MockSearchClient:
|
||||
def __init__(self, client, project_id):
|
||||
self.project_id = project_id
|
||||
|
||||
async def search(self, payload, page, page_size):
|
||||
return SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
title=f"Note in {self.project_id or 'local'}",
|
||||
permalink="notes/example",
|
||||
content="",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=0.5,
|
||||
file_path="/notes/example.md",
|
||||
)
|
||||
],
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
total=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
|
||||
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", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes(
|
||||
query="anything",
|
||||
search_all_projects=True,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert searched_projects == [("alpha", None), ("beta", None)], (
|
||||
"Local fan-out must omit project_id so the recursive search_notes calls "
|
||||
"take the name-routed path."
|
||||
)
|
||||
assert result["total"] == 2
|
||||
@@ -1050,6 +1050,25 @@ async def test_get_all_permalinks(entity_repository: EntityRepository, session_m
|
||||
assert isinstance(permalink, str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_by_ids_for_hydration_skips_eager_load_options(
|
||||
entity_repository: EntityRepository, sample_entity: Entity, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Context hydration should bypass relationship loader options."""
|
||||
|
||||
def fail_get_load_options():
|
||||
raise AssertionError("hydration lookup must not eager load entity relationships")
|
||||
|
||||
monkeypatch.setattr(entity_repository, "get_load_options", fail_get_load_options)
|
||||
|
||||
found = await entity_repository.find_by_ids_for_hydration([sample_entity.id])
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0].id == sample_entity.id
|
||||
assert found[0].title == sample_entity.title
|
||||
assert found[0].external_id == sample_entity.external_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_permalink_to_file_path_map(entity_repository: EntityRepository, session_maker):
|
||||
"""Test getting permalink -> file_path mapping for bulk operations."""
|
||||
@@ -1114,3 +1133,150 @@ async def test_get_file_path_to_permalink_map(entity_repository: EntityRepositor
|
||||
assert len(mapping) == 2
|
||||
assert mapping["test/entity1.md"] == "test/entity1"
|
||||
assert mapping["test/entity2.md"] == "test/entity2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_without_relations_returns_isolated_entities(
|
||||
entity_repository: EntityRepository, session_maker, test_project: Project
|
||||
):
|
||||
"""Entities with no incoming or outgoing relations are returned as orphans."""
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
orphan = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Orphan",
|
||||
note_type="test",
|
||||
permalink="orphan/orphan",
|
||||
file_path="orphan/orphan.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
source = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Source",
|
||||
note_type="test",
|
||||
permalink="source/source",
|
||||
file_path="source/source.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
target = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Target",
|
||||
note_type="test",
|
||||
permalink="target/target",
|
||||
file_path="target/target.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add_all([orphan, source, target])
|
||||
await session.flush()
|
||||
|
||||
relation = Relation(
|
||||
project_id=test_project.id,
|
||||
from_id=source.id,
|
||||
to_id=target.id,
|
||||
to_name=target.title,
|
||||
relation_type="links_to",
|
||||
)
|
||||
session.add(relation)
|
||||
|
||||
result = await entity_repository.find_without_relations()
|
||||
titles = {entity.title for entity in result}
|
||||
|
||||
assert "Orphan" in titles
|
||||
assert "Source" not in titles
|
||||
assert "Target" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_without_relations_excludes_unresolved_outgoing_links(
|
||||
entity_repository: EntityRepository, session_maker, test_project: Project
|
||||
):
|
||||
"""Entities with unresolved outgoing relations are still connected source nodes."""
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
source = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Source",
|
||||
note_type="test",
|
||||
permalink="source/source",
|
||||
file_path="source/source.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
orphan = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Orphan",
|
||||
note_type="test",
|
||||
permalink="orphan/orphan",
|
||||
file_path="orphan/orphan.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add_all([source, orphan])
|
||||
await session.flush()
|
||||
|
||||
unresolved_relation = Relation(
|
||||
project_id=test_project.id,
|
||||
from_id=source.id,
|
||||
to_id=None,
|
||||
to_name="Missing Target",
|
||||
relation_type="links_to",
|
||||
)
|
||||
session.add(unresolved_relation)
|
||||
|
||||
result = await entity_repository.find_without_relations()
|
||||
titles = {entity.title for entity in result}
|
||||
|
||||
assert "Orphan" in titles
|
||||
assert "Source" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_without_relations_respects_project_scope(
|
||||
entity_repository: EntityRepository, session_maker, test_project: Project
|
||||
):
|
||||
"""Orphan detection only returns isolated entities for the active project."""
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
active_orphan = Entity(
|
||||
project_id=test_project.id,
|
||||
title="Active Project Orphan",
|
||||
note_type="test",
|
||||
permalink="active/orphan",
|
||||
file_path="active/orphan.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
other_project = Project(name="other-project", path="/tmp/other")
|
||||
session.add_all([active_orphan, other_project])
|
||||
await session.flush()
|
||||
|
||||
other_orphan = Entity(
|
||||
project_id=other_project.id,
|
||||
title="Other Project Orphan",
|
||||
note_type="test",
|
||||
permalink="other/orphan",
|
||||
file_path="other/orphan.md",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(other_orphan)
|
||||
|
||||
result = await entity_repository.find_without_relations()
|
||||
titles = {entity.title for entity in result}
|
||||
|
||||
assert "Active Project Orphan" in titles
|
||||
assert "Other Project Orphan" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_without_relations_empty_project(entity_repository: EntityRepository):
|
||||
"""An empty project returns no orphans."""
|
||||
result = await entity_repository.find_without_relations()
|
||||
assert result == []
|
||||
|
||||
@@ -235,6 +235,7 @@ async def test_postgres_search_repository_tsquery_syntax_error_returns_empty(
|
||||
# Trailing boolean operator creates an invalid tsquery; repository should return []
|
||||
results = await repo.search(search_text="coffee AND")
|
||||
assert results == []
|
||||
assert await repo.count(search_text="coffee AND") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -318,6 +318,25 @@ async def test_sqlite_hybrid_search_raises_disabled_error(search_repository):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("retrieval_mode", [SearchRetrievalMode.VECTOR, SearchRetrievalMode.HYBRID])
|
||||
async def test_count_rejects_semantic_modes_without_running_search(monkeypatch, retrieval_mode):
|
||||
"""Semantic counts must not materialize vector or hybrid retrieval."""
|
||||
repo = _ConcreteRepo()
|
||||
search_calls = []
|
||||
|
||||
async def fail_if_search_runs(**kwargs):
|
||||
search_calls.append(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(repo, "search", fail_if_search_runs)
|
||||
|
||||
with pytest.raises(ValueError, match="Exact counts are only supported for full-text search"):
|
||||
await repo.count(search_text="semantic query", retrieval_mode=retrieval_mode)
|
||||
|
||||
assert search_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_entity_vectors_batch_flushes_at_configured_threshold(monkeypatch):
|
||||
"""Batch sync should flush queued jobs at semantic_embedding_sync_batch_size boundaries."""
|
||||
|
||||
@@ -7,6 +7,7 @@ from basic_memory.schema.parser import (
|
||||
parse_picoschema,
|
||||
parse_schema_note,
|
||||
_parse_field_key,
|
||||
_parse_field_key_parts,
|
||||
_parse_type_and_description,
|
||||
_parse_enum_string,
|
||||
_is_entity_ref_type,
|
||||
@@ -43,18 +44,50 @@ class TestParseFieldKey:
|
||||
assert required is False
|
||||
assert is_array is True
|
||||
|
||||
def test_array_with_description_in_modifier(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key("tags(array, list of tags)")
|
||||
assert name == "tags"
|
||||
assert required is True
|
||||
assert is_array is True
|
||||
assert is_enum is False
|
||||
assert is_object is False
|
||||
|
||||
def test_optional_array_with_description_in_modifier(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key(
|
||||
"tags?(array, list of tags)"
|
||||
)
|
||||
assert name == "tags"
|
||||
assert required is False
|
||||
assert is_array is True
|
||||
|
||||
def test_enum(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key("status?(enum)")
|
||||
assert name == "status"
|
||||
assert required is False
|
||||
assert is_enum is True
|
||||
|
||||
def test_enum_with_description_in_modifier(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key(
|
||||
"status(enum, current state)"
|
||||
)
|
||||
assert name == "status"
|
||||
assert required is True
|
||||
assert is_enum is True
|
||||
|
||||
def test_object(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key("metadata?(object)")
|
||||
assert name == "metadata"
|
||||
assert required is False
|
||||
assert is_object is True
|
||||
|
||||
def test_object_with_description_in_modifier(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key(
|
||||
"metadata?(object, nested metadata)"
|
||||
)
|
||||
assert name == "metadata"
|
||||
assert required is False
|
||||
assert is_object is True
|
||||
|
||||
def test_required_enum(self):
|
||||
name, required, is_array, is_enum, is_object = _parse_field_key("status(enum)")
|
||||
assert name == "status"
|
||||
@@ -62,6 +95,68 @@ class TestParseFieldKey:
|
||||
assert is_enum is True
|
||||
|
||||
|
||||
class TestParseFieldKeyParts:
|
||||
def test_modifier_description_returned(self):
|
||||
assert _parse_field_key_parts("tags(array, list of tags)") == (
|
||||
"tags",
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
"list of tags",
|
||||
)
|
||||
|
||||
def test_optional_enum_description_returned(self):
|
||||
assert _parse_field_key_parts("status?(enum, current state)") == (
|
||||
"status",
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
"current state",
|
||||
)
|
||||
|
||||
def test_no_modifier_description_is_none(self):
|
||||
assert _parse_field_key_parts("name") == (
|
||||
"name",
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_description_can_contain_parentheses(self):
|
||||
assert _parse_field_key_parts("tags(array, labels (freeform))") == (
|
||||
"tags",
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
"labels (freeform)",
|
||||
)
|
||||
|
||||
def test_field_name_can_contain_parentheses_before_modifier(self):
|
||||
assert _parse_field_key_parts("risk(score)(array)") == (
|
||||
"risk(score)",
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_optional_field_name_can_contain_parentheses_before_modifier(self):
|
||||
assert _parse_field_key_parts("risk(score)?(array, score buckets)") == (
|
||||
"risk(score)",
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
"score buckets",
|
||||
)
|
||||
|
||||
|
||||
# --- _parse_type_and_description ---
|
||||
|
||||
|
||||
@@ -152,6 +247,19 @@ class TestParsePicoschema:
|
||||
assert fields[0].is_array is True
|
||||
assert fields[0].required is False
|
||||
|
||||
def test_array_field_with_description_in_modifier(self):
|
||||
fields = parse_picoschema({"tags(array, list of tags)": "string"})
|
||||
assert fields[0].name == "tags"
|
||||
assert fields[0].type == "string"
|
||||
assert fields[0].is_array is True
|
||||
assert fields[0].description == "list of tags"
|
||||
|
||||
def test_parenthesized_field_name_with_array_modifier(self):
|
||||
fields = parse_picoschema({"risk(score)(array)": "string"})
|
||||
assert fields[0].name == "risk(score)"
|
||||
assert fields[0].type == "string"
|
||||
assert fields[0].is_array is True
|
||||
|
||||
def test_entity_ref_field(self):
|
||||
fields = parse_picoschema({"works_at?": "Organization, employer"})
|
||||
assert fields[0].name == "works_at"
|
||||
@@ -165,6 +273,15 @@ class TestParsePicoschema:
|
||||
assert fields[0].is_enum is True
|
||||
assert fields[0].enum_values == ["active", "inactive"]
|
||||
|
||||
def test_enum_field_with_description_in_modifier(self):
|
||||
fields = parse_picoschema({"status(enum, current state)": ["active", "inactive"]})
|
||||
assert fields[0].name == "status"
|
||||
assert fields[0].type == "enum"
|
||||
assert fields[0].required is True
|
||||
assert fields[0].is_enum is True
|
||||
assert fields[0].enum_values == ["active", "inactive"]
|
||||
assert fields[0].description == "current state"
|
||||
|
||||
def test_enum_field_with_string(self):
|
||||
fields = parse_picoschema({"status?(enum)": "active"})
|
||||
assert fields[0].is_enum is True
|
||||
@@ -204,6 +321,20 @@ class TestParsePicoschema:
|
||||
assert fields[0].children[0].name == "street"
|
||||
assert fields[0].children[1].name == "city"
|
||||
|
||||
def test_object_field_with_description_in_modifier(self):
|
||||
fields = parse_picoschema(
|
||||
{
|
||||
"address?(object, mailing address)": {
|
||||
"street": "string",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert fields[0].name == "address"
|
||||
assert fields[0].type == "object"
|
||||
assert fields[0].required is False
|
||||
assert fields[0].description == "mailing address"
|
||||
assert fields[0].children[0].name == "street"
|
||||
|
||||
def test_dict_value_treated_as_object(self):
|
||||
"""A dict value without explicit (object) is still treated as an object."""
|
||||
fields = parse_picoschema(
|
||||
@@ -315,6 +446,31 @@ class TestParseSchemaNote:
|
||||
assert status_field.is_enum is True
|
||||
assert status_field.enum_values == ["draft", "published"]
|
||||
|
||||
def test_settings_frontmatter_parses_modifier_descriptions(self):
|
||||
frontmatter = {
|
||||
"entity": "Person",
|
||||
"schema": {"name": "string"},
|
||||
"settings": {
|
||||
"validation": "warn",
|
||||
"frontmatter": {
|
||||
"tags?(array, note tags)": "string",
|
||||
"status?(enum, publication state)": ["draft", "published"],
|
||||
},
|
||||
},
|
||||
}
|
||||
result = parse_schema_note(frontmatter)
|
||||
|
||||
tags_field = next(f for f in result.frontmatter_fields if f.name == "tags")
|
||||
assert tags_field.required is False
|
||||
assert tags_field.is_array is True
|
||||
assert tags_field.description == "note tags"
|
||||
|
||||
status_field = next(f for f in result.frontmatter_fields if f.name == "status")
|
||||
assert status_field.required is False
|
||||
assert status_field.is_enum is True
|
||||
assert status_field.enum_values == ["draft", "published"]
|
||||
assert status_field.description == "publication state"
|
||||
|
||||
def test_no_settings_frontmatter_defaults_to_empty(self):
|
||||
frontmatter = {
|
||||
"entity": "Person",
|
||||
|
||||
@@ -127,6 +127,7 @@ def test_search_response():
|
||||
metadata={},
|
||||
),
|
||||
]
|
||||
response = SearchResponse(results=results, current_page=1, page_size=1)
|
||||
response = SearchResponse(results=results, current_page=1, page_size=1, total=2)
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].score > response.results[1].score
|
||||
assert response.total == 2
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user