mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3480e917e2 | |||
| 6ac28aeee9 | |||
| ff5d872a8c | |||
| 96ee4eafd2 | |||
| b109b7337f | |||
| 36b51b676e | |||
| 9af320187c | |||
| 5a34a420c9 | |||
| a7e2368f9e | |||
| c755127317 | |||
| 94c04ee456 | |||
| d4ed02ba74 | |||
| 40ed7129c8 | |||
| c4ef7abff5 | |||
| c75f45f3cf | |||
| 5ae8a733ea | |||
| b94ef01f82 | |||
| 12af7930de | |||
| 60ec6728de | |||
| a84e77f4af | |||
| 6910620968 | |||
| 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 | |||
| a661e924df | |||
| 05adda1502 | |||
| 0a72d81bb3 | |||
| 5ccf433cad | |||
| b4bf14ebf7 | |||
| 0b335476d6 | |||
| 2fccc74a20 | |||
| c4956cac16 | |||
| 128c2da40c | |||
| 2bfb9c76df | |||
| 3d927b848f | |||
| 953fe20aef | |||
| a4282d9f2f | |||
| 799dd6c629 | |||
| 26e74ea118 | |||
| ee1558ea68 | |||
| 4d62b623db | |||
| 2fe4488eda | |||
| f3e46d7984 | |||
| 56d6f1b4a5 | |||
| 1b39062ecd | |||
| c4cf0aff1e | |||
| 1c343bed66 | |||
| c50d97e548 | |||
| e2e65575d6 | |||
| bf9a6b4a75 | |||
| 474100efef | |||
| b3d5448355 | |||
| 4e53bb83fd | |||
| 8f2b25f0e0 | |||
| 052545b661 |
@@ -3,7 +3,6 @@
|
||||
"env": {
|
||||
"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1",
|
||||
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"CLAUDE_CODE_NO_FLICKER": "1",
|
||||
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING": "1"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -60,26 +63,63 @@ jobs:
|
||||
# Only run for stable releases (not dev, beta, or rc versions)
|
||||
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
|
||||
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
|
||||
# with the bearer token and expects a 302 redirect. GitHub now returns
|
||||
# 303 on that endpoint when authenticated, which the action treats as a
|
||||
# fatal error. Re-implementing the bump as plain git+sed keeps the same
|
||||
# contract (update url + sha256, commit, push) with no third-party action.
|
||||
- name: Update Homebrew formula
|
||||
uses: mislav/bump-homebrew-formula-action@v3
|
||||
with:
|
||||
# Formula name in homebrew-basic-memory repo
|
||||
formula-name: basic-memory
|
||||
# The tap repository
|
||||
homebrew-tap: basicmachines-co/homebrew-basic-memory
|
||||
# Base branch of the tap repository
|
||||
base-branch: main
|
||||
# Download URL will be automatically constructed from the tag
|
||||
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
|
||||
# Commit message for the formula update
|
||||
commit-message: |
|
||||
{{formulaName}} {{version}}
|
||||
|
||||
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
|
||||
env:
|
||||
# Personal Access Token with repo scope for homebrew-basic-memory repo
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
REF: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${REF#v}"
|
||||
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
|
||||
|
||||
echo "::group::Compute tarball sha256"
|
||||
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
|
||||
test -n "$SHA256"
|
||||
echo "sha256: $SHA256"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Clone tap"
|
||||
git clone \
|
||||
--depth 1 \
|
||||
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
|
||||
tap
|
||||
cd tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Patch Formula/basic-memory.rb"
|
||||
# Pipe-delimited sed because the URL contains slashes. The Formula
|
||||
# only has one `url` and one `sha256` directive, so a first-match
|
||||
# replacement is unambiguous. POSIX character classes ([[:space:]])
|
||||
# keep this portable across BSD and GNU sed.
|
||||
sed -i -E \
|
||||
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
|
||||
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
|
||||
Formula/basic-memory.rb
|
||||
git --no-pager diff Formula/basic-memory.rb
|
||||
echo "::endgroup::"
|
||||
|
||||
if git diff --quiet Formula/basic-memory.rb; then
|
||||
echo "Formula already at ${REF}; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::group::Commit & push"
|
||||
git add Formula/basic-memory.rb
|
||||
git commit -m "basic-memory ${VERSION}
|
||||
|
||||
Created by ${RUN_URL}"
|
||||
git push origin HEAD:main
|
||||
echo "::endgroup::"
|
||||
|
||||
+27
-22
@@ -5,9 +5,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
# Trigger: PR branch pushes already publish commit statuses that show up on the PR.
|
||||
# Why: running the full matrix on both push and pull_request doubles CI time for the
|
||||
# exact same branch head commit.
|
||||
# Outcome: each branch push runs the test suite once, including PR updates.
|
||||
push:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
static-checks:
|
||||
@@ -16,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"
|
||||
@@ -30,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -50,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:
|
||||
@@ -66,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'
|
||||
@@ -80,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -112,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'
|
||||
@@ -126,7 +131,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -142,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:
|
||||
@@ -169,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'
|
||||
@@ -183,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -226,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'
|
||||
@@ -240,7 +245,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -260,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"
|
||||
@@ -274,7 +279,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
|
||||
@@ -22,16 +22,16 @@ See the [README.md](README.md) file for a project overview.
|
||||
- Run unit tests (Postgres): `just test-unit-postgres`
|
||||
- Run integration tests (SQLite): `just test-int-sqlite`
|
||||
- Run integration tests (Postgres): `just test-int-postgres`
|
||||
- Run impacted tests: `just testmon` (pytest-testmon)
|
||||
- Run impacted tests: `just testmon` (pytest-testmon; only tests affected by changed code)
|
||||
- Run MCP smoke test: `just test-smoke`
|
||||
- Fast local loop: `just fast-check`
|
||||
- Fast local loop: `just fast-check` (default iteration flow)
|
||||
- Local consistency check: `just doctor`
|
||||
- Generate HTML coverage: `just coverage`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
|
||||
- Lint: `just lint` or `ruff check . --fix`
|
||||
- Type check: `just typecheck` or `uv run pyright`
|
||||
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
|
||||
- Type check: `just typecheck` or `uv run ty check src tests test-int`
|
||||
- Type check (pyright): `just typecheck-pyright` or `uv run pyright`
|
||||
- Format: `just format` or `uv run ruff format .`
|
||||
- Run all code checks: `just check` (runs lint, format, typecheck, test)
|
||||
- Create db migration: `just migration "Your migration message"`
|
||||
@@ -48,12 +48,23 @@ See the [README.md](README.md) file for a project overview.
|
||||
### Code/Test/Verify Loop (fast path)
|
||||
|
||||
1) **Code:** make changes.
|
||||
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
|
||||
2) **Test:** `just fast-check` (lint/format/typecheck + pytest-testmon impacted tests for changed code).
|
||||
3) **Verify:** `just doctor` (end-to-end file ↔ DB loop in a temp project).
|
||||
4) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
|
||||
|
||||
Run `just test-smoke` when you specifically need the MCP smoke flow.
|
||||
|
||||
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
|
||||
|
||||
### PR CI Gate
|
||||
|
||||
Before opening or updating a PR, run the checks that mirror the common required CI failures:
|
||||
|
||||
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
|
||||
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
|
||||
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
|
||||
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- `tests/` - Unit tests for individual components (mocked, fast)
|
||||
@@ -242,6 +253,31 @@ async_client.set_client_factory(your_custom_factory)
|
||||
|
||||
See SPEC-16 for full context manager refactor details.
|
||||
|
||||
### Release Process
|
||||
|
||||
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
|
||||
|
||||
**Stable release:**
|
||||
|
||||
```
|
||||
just release v0.21.3
|
||||
```
|
||||
|
||||
The recipe runs `just lint` + `just typecheck`, then updates `__version__` in `src/basic_memory/__init__.py` and `"version"` in `server.json` (MCP registry metadata), commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the package, publishes to PyPI, creates the GitHub release with auto-generated notes, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
|
||||
|
||||
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
|
||||
|
||||
**Development builds:** every commit to `main` publishes a `0.21.3.dev26+468a22f`-style version to PyPI automatically via `.github/workflows/dev-release.yml`. No human action.
|
||||
|
||||
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
|
||||
|
||||
**Post-release tasks** the recipe surfaces but doesn't run:
|
||||
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
|
||||
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
|
||||
- MCP Registry — `mcp-publisher publish` from the repo root
|
||||
|
||||
See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `changelog.md` alongside it) for the full release + post-release runbook, including the slash commands.
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
### Knowledge Structure
|
||||
|
||||
+153
-6
@@ -1,6 +1,153 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
## v0.21.5 (2026-05-26)
|
||||
|
||||
Workspace/project routing fixes for MCP, plus a SQLite vector reindex stability fix.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#854**: MCP project listing now keeps duplicate cloud project rows distinct by
|
||||
workspace, so only the selected workspace row inherits local project state.
|
||||
- **#853**: `write_note` returns workspace-qualified permalinks for cloud
|
||||
workspace writes, allowing follow-up `memory://` reads to route back to the
|
||||
correct workspace/project.
|
||||
- **#852**: Full SQLite vector reindex now loads `sqlite-vec` before dropping
|
||||
`vec0` virtual tables, preventing reindex crashes.
|
||||
- **#838**: Local ASGI database initialization is preloaded so MCP routing can
|
||||
safely enter local project contexts.
|
||||
|
||||
## v0.21.1 (2026-05-16)
|
||||
|
||||
CI-only release. No user-facing changes.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **#833**: Replace `mislav/bump-homebrew-formula-action` with an inline
|
||||
bash bump step. The action's `resolveRedirect` HEAD-requests
|
||||
`api.github.com /repos/.../tarball/<ref>` expecting HTTP 302; GitHub now
|
||||
returns 303 for authenticated requests on that endpoint, which broke
|
||||
the v0.21.0 Homebrew job and required a manual tap bump. The inline
|
||||
step does the same work (curl + sha256sum, clone tap, sed-bump
|
||||
`url`/`sha256`, commit, push) without any third-party dependency.
|
||||
|
||||
## v0.21.0 (2026-05-16)
|
||||
|
||||
Workspace-aware everywhere: every MCP tool and CLI command now routes through the
|
||||
same workspace/project model, the search and sync paths are noticeably faster,
|
||||
and a handful of long-standing parsing and routing footguns are gone.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Relation parsing no longer treats unquoted multi-word text before a wikilink as a
|
||||
custom relation type. Use single-token relation types like `relates_to [[Target]]`,
|
||||
or quote multi-word relation types like `"relates to" [[Target]]` or
|
||||
`'relates to' [[Target]]`.
|
||||
- Bare list wikilinks like `- [[Target]]` now index as `links_to`.
|
||||
- Prose list items like `- some other thing [[Target]]` now index as `links_to`.
|
||||
- To preserve existing multi-word relation types on re-sync, quote them before upgrading.
|
||||
- See **#824**.
|
||||
|
||||
### Features
|
||||
|
||||
- **#816**: `bm orphan` CLI command surfaces entities whose underlying markdown
|
||||
files are gone, with a flag to clean them out.
|
||||
- **#789**: Create projects directly by cloud workspace slug from MCP
|
||||
(`create_memory_project(workspace=...)`).
|
||||
- **#757**: Discover projects across every accessible cloud workspace in MCP's
|
||||
project list — no more per-workspace blind spots.
|
||||
- **#766**: MCP tools accept training-data-friendly parameter aliases
|
||||
(`q`/`search`/`text` for `query`, etc.) so models reach for them naturally.
|
||||
- **#776**: `bm db reset` refuses to run while a `basic-memory mcp` process is
|
||||
alive, so resets can no longer corrupt an open session.
|
||||
- **#791**: Search responses include result totals so pagers can stop guessing.
|
||||
- **#719**: Cloud `note_content` tenant schema primitive lands on the backend.
|
||||
- **#715**: `bm project add` accepts a `--visibility` flag for cloud projects.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Search and recent activity
|
||||
- **#832**: SQLite project deletion now sweeps `search_index`,
|
||||
`search_vector_chunks`, and `search_vector_embeddings`, so a project that
|
||||
reuses a recycled auto-increment id can't inherit the previous tenant's content.
|
||||
- **#812**: `recent_activity` orders and filters by `updated_at`, so edits bubble
|
||||
to the top instead of staying pinned to creation time.
|
||||
- **#807**: Multi-project `search_notes` is opt-in (`search_all_projects=True`);
|
||||
default search stays scoped to the resolved project.
|
||||
- **#785**: `recent_activity` caps responses and emits an explicit truncation
|
||||
footer instead of silently dropping rows.
|
||||
- **#713**: Eliminated an N+1 query in search result hydration.
|
||||
|
||||
#### Workspace / project routing
|
||||
- **#822**: `bm project list` now includes projects from every workspace, not
|
||||
just the current one.
|
||||
- **#813**, **#808**, **#806**, **#803**, **#801**, **#795**, **#790**, **#778**,
|
||||
**#777**, **#722**, **#712**, **#704**: Workspace-qualified permalink routing
|
||||
is centralized and applied consistently across `edit_note`, `delete_project`,
|
||||
`build_context`, `memory://` URLs, factory-mode project listing, cloud uploads,
|
||||
and the API client.
|
||||
|
||||
#### Sync
|
||||
- **#827**: `rclone bisync` filters from `.bmignore` are preserved across syncs.
|
||||
- **#815**: Watch service ignores hidden paths relative to the watched project,
|
||||
not just relative to `$HOME`.
|
||||
- **#814**: `scan` subprocesses no longer go through the shell, avoiding quoting
|
||||
issues with paths that contain special characters.
|
||||
- **#759**: Watch service stays inside `--project` scope.
|
||||
- **#746**: Canonical markdown is preserved during single-file sync.
|
||||
|
||||
#### Parsing
|
||||
- **#796**: Picoschema modifier descriptions (`field?(modifier, description)`)
|
||||
parse correctly.
|
||||
- **#769**: Obsidian callout blocks are skipped by the observation parser
|
||||
instead of being mis-extracted as observations.
|
||||
|
||||
#### CLI
|
||||
- **#775**: `bm project set-cloud` / `set-local` cleans up local DB state for
|
||||
the affected project.
|
||||
- **#773**: `bm cloud logout` clears `default_workspace`.
|
||||
- **#780**: `bm cloud setup` hint points at `bm cloud sync-setup`.
|
||||
- **#734**: `bm project info` shows cloud index freshness.
|
||||
- **#718**: Private cloud projects display under their `display_name` instead of
|
||||
raw UUID.
|
||||
- **#768**: `read_note` / `view_note` drop no-op pagination params from their
|
||||
signatures.
|
||||
|
||||
#### Stability
|
||||
- **#774**: `sqlite-vec` failures during init degrade gracefully to keyword-only
|
||||
search instead of crashing startup.
|
||||
- **#733**: Delete-vector and cloud-sync cleanup is now consistent.
|
||||
- **#702**: Race conditions in concurrent `delete_entity` are resolved.
|
||||
- **#724**: `external_id` is preserved when entities are re-upserted during a
|
||||
re-index.
|
||||
- **#728**: Vector init no longer issues runtime `ALTER TABLE`.
|
||||
- **#744**: `BASIC_MEMORY_CONFIG_DIR` is honored across remaining call sites.
|
||||
- **#743**: FastEmbed cache lives under the data dir instead of `/tmp`.
|
||||
- **#752**: Cloud projects report `source=cloud` in factory mode.
|
||||
- Stripped null bytes from markdown content before DB insert.
|
||||
- Allowed long `relation_type` values in API responses.
|
||||
|
||||
#### Installer
|
||||
- **#772**: Docker-compose config volume mounts under the `appuser` home.
|
||||
- **#695**: Bumped `brew outdated` timeout from 15s to 60s.
|
||||
|
||||
### Performance
|
||||
|
||||
- **#828**: CLI startup no longer pulls in the local ASGI FastAPI app when it
|
||||
isn't needed.
|
||||
- **#751**, **#726**, **#717**, **#714**, single-file/batch indexing: marked
|
||||
speedups on the sync hot path; unchanged markdown is skipped entirely.
|
||||
- **#731**, **#723**: Vector sync is faster on both backends, with tuned
|
||||
fastembed defaults.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **#825**: Dependency refresh + security hardening.
|
||||
- Updated to `fastmcp` 3.3.1.
|
||||
- **#754**: Removed in-house telemetry wrappers in favor of direct `logfire`
|
||||
usage.
|
||||
- **#736**: `ty` is now the default typechecker.
|
||||
- **#771**, **#770**, **#716**: New regression guards for vector-row cleanup,
|
||||
long relation types, and recent activity hydration.
|
||||
|
||||
## v0.20.3 (2026-03-26)
|
||||
|
||||
@@ -2125,12 +2272,12 @@ Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
- Update CLAUDE.md ([#33](https://github.com/basicmachines-co/basic-memory/pull/33),
|
||||
[`dfaf0fe`](https://github.com/basicmachines-co/basic-memory/commit/dfaf0fea9cf5b97d169d51a6276ec70162c21a7e))
|
||||
|
||||
fix spelling in CLAUDE.md: enviroment -> environment Signed-off-by: Ikko Eltociear Ashimine
|
||||
fix spelling in CLAUDE.md: environment typo Signed-off-by: Ikko Eltociear Ashimine
|
||||
<eltociear@gmail.com>
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Move project stats into projct subcommand
|
||||
- Move project stats into project subcommand
|
||||
([`2a881b1`](https://github.com/basicmachines-co/basic-memory/commit/2a881b1425c73947f037fbe7ac5539c015b62526))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
@@ -2559,7 +2706,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Refix vitual env in installer build
|
||||
- Refix virtual env in installer build
|
||||
([`052f491`](https://github.com/basicmachines-co/basic-memory/commit/052f491fff629e8ead629c9259f8cb46c608d584))
|
||||
|
||||
|
||||
@@ -2578,7 +2725,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix path to intaller app artifact
|
||||
- Fix path to installer app artifact
|
||||
([`53d220d`](https://github.com/basicmachines-co/basic-memory/commit/53d220df585561f9edd0d49a9e88f1d4055059cf))
|
||||
|
||||
|
||||
@@ -2586,7 +2733,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Activate vitualenv in installer build
|
||||
- Activate virtualenv in installer build
|
||||
([`d4c8293`](https://github.com/basicmachines-co/basic-memory/commit/d4c8293687a52eaf3337fe02e2f7b80e4cc9a1bb))
|
||||
|
||||
- Trigger installer build on release
|
||||
|
||||
@@ -224,40 +224,6 @@ See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
|
||||
- **Fixtures**: Use async pytest fixtures for setup and teardown
|
||||
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
|
||||
## Release Process
|
||||
|
||||
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
|
||||
|
||||
### Version Management
|
||||
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
|
||||
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
|
||||
|
||||
### Release Workflows
|
||||
|
||||
#### Development Builds
|
||||
- Automatically published to PyPI on every commit to `main`
|
||||
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
|
||||
- Users install with: `pip install basic-memory --pre --force-reinstall`
|
||||
|
||||
#### Beta Releases
|
||||
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
|
||||
2. GitHub Actions automatically builds and publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory --pre`
|
||||
|
||||
#### Stable Releases
|
||||
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
2. GitHub Actions automatically:
|
||||
- Builds the package with version `0.13.0`
|
||||
- Creates GitHub release with auto-generated notes
|
||||
- Publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory`
|
||||
|
||||
### For Contributors
|
||||
- No manual version bumping required
|
||||
- Versions are automatically derived from git tags
|
||||
- Focus on code changes, not version management
|
||||
|
||||
## Creating Issues
|
||||
|
||||
If you're planning to work on something, please create an issue first to discuss the approach. Include:
|
||||
|
||||
+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.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Docker Compose configuration for Basic Memory with PostgreSQL
|
||||
# Use this for local development and testing with Postgres backend
|
||||
# Use this for local development and testing with Postgres backend.
|
||||
#
|
||||
# The Postgres backend requires the pgvector extension (semantic search).
|
||||
# This image bundles pgvector; plain postgres:17 will not work for vector search.
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
@@ -7,7 +10,7 @@
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: basic-memory-postgres
|
||||
environment:
|
||||
# Local development/test credentials - NOT for production
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ services:
|
||||
volumes:
|
||||
|
||||
# Persistent storage for configuration and database
|
||||
- basic-memory-config:/root/.basic-memory:rw
|
||||
# Container runs as `appuser` (Dockerfile USER directive), so the CLI
|
||||
# config dir lives under /home/appuser, not /root.
|
||||
- basic-memory-config:/home/appuser/.basic-memory:rw
|
||||
|
||||
# Mount your knowledge directory (required)
|
||||
# Change './knowledge' to your actual Obsidian vault or knowledge directory
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -91,10 +91,11 @@ SQLite Database (Index)
|
||||
# List all projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
# Response structure:
|
||||
# Response structure (each entry includes external_id you can pass as project_id):
|
||||
# [
|
||||
# {
|
||||
# "name": "main",
|
||||
# "external_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
# "path": "/Users/name/notes",
|
||||
# "is_default": True,
|
||||
# "note_count": 156,
|
||||
@@ -102,6 +103,7 @@ projects = await list_memory_projects()
|
||||
# },
|
||||
# {
|
||||
# "name": "work",
|
||||
# "external_id": "9f86d081-884c-42a3-b5e3-1c0c5b4c8e52",
|
||||
# "path": "/Users/name/work-notes",
|
||||
# "is_default": False,
|
||||
# "note_count": 89,
|
||||
@@ -164,6 +166,44 @@ active_project = "main"
|
||||
results = await search_notes(query="topic", project=active_project)
|
||||
```
|
||||
|
||||
### `project` vs `project_id`
|
||||
|
||||
Every project has two identifiers:
|
||||
|
||||
- **`project`** — human-readable name (e.g., `"main"`). Easy to use, but can collide across cloud workspaces.
|
||||
- **`project_id`** — stable `external_id` UUID. Always unambiguous; takes precedence over `project` when both are passed.
|
||||
|
||||
**When to prefer `project_id`:**
|
||||
|
||||
1. **Cloud multi-workspace setups.** If the user belongs to more than one workspace (personal + organization, or several organizations) and the same project name might exist in more than one of them, pass `project_id` to route to the exact project. Without it, name resolution falls back to the default workspace, which may not be the one the user means.
|
||||
2. **After `list_memory_projects()`.** Once you have the `external_id`, prefer using it — it's the same number of characters in JSON and saves a name-resolution round-trip.
|
||||
3. **When persisting a project choice across a long session.** UUIDs are stable; names can be renamed.
|
||||
|
||||
**When `project` (name) is fine:**
|
||||
|
||||
- Local single-workspace setups (no collision risk).
|
||||
- One-off operations where the name is clearly visible to the user (e.g., quick `search_notes(project="main", ...)`).
|
||||
- The user explicitly references a project by name in their message.
|
||||
|
||||
**Example — cloud multi-workspace pattern:**
|
||||
|
||||
```python
|
||||
# Discover and pick the right project for this user
|
||||
projects = await list_memory_projects()
|
||||
target = next(p for p in projects if p["name"] == "research" and p["workspace"]["slug"] == "acme")
|
||||
|
||||
# Use the UUID for all subsequent operations — no ambiguity
|
||||
await write_note(
|
||||
title="Meeting Notes",
|
||||
content="...",
|
||||
folder="meetings",
|
||||
project_id=target["external_id"],
|
||||
)
|
||||
results = await search_notes(query="kickoff", project_id=target["external_id"])
|
||||
```
|
||||
|
||||
**Precedence rule:** When both are passed, `project_id` wins. This lets you safely supply `project="main"` for backward compatibility while still routing precisely with `project_id`.
|
||||
|
||||
### Cross-Project Operations
|
||||
|
||||
**Some tools work across all projects when project parameter omitted:**
|
||||
|
||||
+100
-29
@@ -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:
|
||||
@@ -113,7 +118,7 @@ bm project add research --cloud
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
|
||||
# Or configure sync for existing project
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
```
|
||||
|
||||
**What happens under the covers:**
|
||||
@@ -236,7 +241,7 @@ bm project add research --cloud --local-path ~/Documents/research
|
||||
|
||||
```bash
|
||||
# Project already exists on cloud
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -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"
|
||||
@@ -739,7 +808,7 @@ bm project sync --name research
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -795,7 +866,7 @@ bm project list --local # Local project list
|
||||
bm project list --cloud # Cloud project list
|
||||
bm project add <name> --cloud # Create cloud project (no sync)
|
||||
bm project add <name> --cloud --local-path <path> # Create with local sync
|
||||
bm project sync-setup <name> <path> # Add sync to existing project
|
||||
bm cloud sync-setup <name> <path> # Add sync to existing project
|
||||
bm project rm <name> # Delete project
|
||||
```
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ The sqlite-vec extension is loaded per-connection. Vector tables are created laz
|
||||
### Postgres (cloud)
|
||||
|
||||
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
|
||||
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
|
||||
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
|
||||
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
|
||||
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
|
||||
|
||||
@@ -62,20 +62,20 @@ test-int-postgres:
|
||||
fi
|
||||
|
||||
# Run tests impacted by recent changes (requires pytest-testmon)
|
||||
# Pass paths or node ids after `just testmon` to limit the candidate set further.
|
||||
testmon *args:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon --testmon-forceselect {{args}}
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon {{args}}
|
||||
|
||||
# Run MCP smoke test (fast end-to-end loop)
|
||||
test-smoke:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
|
||||
|
||||
# Fast local loop: lint, format, typecheck, impacted tests
|
||||
# Fast local loop: lint, format, typecheck, impacted tests via pytest-testmon
|
||||
fast-check:
|
||||
just fix
|
||||
just format
|
||||
just typecheck
|
||||
just testmon
|
||||
just test-smoke
|
||||
|
||||
# Reset Postgres test database (drops and recreates schema)
|
||||
# Useful when Alembic migration state gets out of sync during development
|
||||
@@ -170,13 +170,17 @@ lint: fix
|
||||
fix:
|
||||
uv run ruff check --fix --unsafe-fixes src tests test-int
|
||||
|
||||
# Type check code (pyright)
|
||||
# Type check code (ty)
|
||||
typecheck:
|
||||
uv run ty check src tests test-int
|
||||
|
||||
# Type check code (pyright)
|
||||
typecheck-pyright:
|
||||
uv run pyright
|
||||
|
||||
# Type check code (ty)
|
||||
typecheck-ty:
|
||||
uv run ty check src/
|
||||
just typecheck
|
||||
|
||||
# Clean build artifacts and cache files
|
||||
clean:
|
||||
|
||||
+12
-6
@@ -25,15 +25,16 @@ 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",
|
||||
"aiofiles>=24.1.0", # Optional observability (disabled by default via config)
|
||||
"aiofiles>=24.1.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
|
||||
"pytest-asyncio>=1.2.0",
|
||||
@@ -47,6 +48,8 @@ dependencies = [
|
||||
"fastembed>=0.7.4",
|
||||
"sqlite-vec>=0.1.6",
|
||||
"openai>=1.100.2",
|
||||
"logfire>=4.19.0",
|
||||
"psutil>=5.9.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -58,9 +61,6 @@ Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
|
||||
basic-memory = "basic_memory.cli.main:app"
|
||||
bm = "basic_memory.cli.main:app"
|
||||
|
||||
[project.optional-dependencies]
|
||||
telemetry = ["logfire>=4.19.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -71,6 +71,12 @@ addopts = "--cov=basic_memory --cov-report term-missing"
|
||||
testpaths = ["tests", "test-int"]
|
||||
asyncio_mode = "strict"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning:testcontainers\\.core\\.waiting_utils",
|
||||
"ignore:The default datetime adapter is deprecated as of Python 3\\.12.*:DeprecationWarning:aiosqlite\\.core",
|
||||
"ignore:codecs\\.open\\(\\) is deprecated\\. Use open\\(\\) instead\\.:DeprecationWarning:frontmatter",
|
||||
"ignore:Parsing dates involving a day of month without a year specified is ambiguous.*:DeprecationWarning:dateparser\\.utils\\.strptime",
|
||||
]
|
||||
markers = [
|
||||
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
|
||||
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.20.3",
|
||||
"version": "0.21.5",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.20.3",
|
||||
"version": "0.21.5",
|
||||
"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.5"
|
||||
|
||||
# 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
|
||||
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
@@ -25,10 +26,16 @@ from basic_memory.api.v2.routers.project_router import (
|
||||
list_projects,
|
||||
synchronize_projects,
|
||||
)
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.config import init_api_logging
|
||||
from basic_memory.services.exceptions import EntityAlreadyExistsError
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.workspace_context import (
|
||||
WORKSPACE_SLUG_HEADER,
|
||||
WORKSPACE_TYPE_HEADER,
|
||||
workspace_permalink_context_validation_error,
|
||||
workspace_permalink_context,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,7 +51,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
set_container(container)
|
||||
app.state.container = container
|
||||
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.lifecycle.startup",
|
||||
entrypoint="api",
|
||||
mode=container.mode.name.lower(),
|
||||
@@ -69,7 +76,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
yield
|
||||
|
||||
# Shutdown - coordinator handles clean task cancellation
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.lifecycle.shutdown",
|
||||
entrypoint="api",
|
||||
mode=container.mode.name.lower(),
|
||||
@@ -87,6 +94,32 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def workspace_permalink_context_middleware(request: Request, call_next):
|
||||
"""Populate workspace permalink context from request headers."""
|
||||
workspace_slug = request.headers.get(WORKSPACE_SLUG_HEADER)
|
||||
workspace_type = request.headers.get(WORKSPACE_TYPE_HEADER)
|
||||
|
||||
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
|
||||
if validation_error is not None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": validation_error},
|
||||
)
|
||||
|
||||
if not workspace_slug:
|
||||
return await call_next(request)
|
||||
|
||||
# ContextVar state remains active across the awaited downstream handler while
|
||||
# this context manager is open, so entity creation can see request metadata.
|
||||
with workspace_permalink_context(
|
||||
workspace_slug=workspace_slug,
|
||||
workspace_type=workspace_type,
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
|
||||
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
||||
@@ -146,4 +179,7 @@ async def exception_handler(request, exc): # pragma: no cover
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
)
|
||||
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
|
||||
return await http_exception_handler(
|
||||
request,
|
||||
HTTPException(status_code=500, detail="Internal server error"),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,10 +10,10 @@ Key improvements:
|
||||
- Simplified caching strategies
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path, Query
|
||||
from fastapi import APIRouter, HTTPException, Response, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.deps import (
|
||||
EntityServiceV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
@@ -24,7 +24,6 @@ from basic_memory.deps import (
|
||||
RelationRepositoryV2ExternalDep,
|
||||
ProjectExternalIdPathDep,
|
||||
TaskSchedulerDep,
|
||||
FileServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -39,6 +38,7 @@ from basic_memory.schemas.v2 import (
|
||||
MoveEntityRequestV2,
|
||||
MoveDirectoryRequestV2,
|
||||
DeleteDirectoryRequestV2,
|
||||
OrphanEntitiesResponse,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
|
||||
@@ -75,34 +75,72 @@ async def get_graph(
|
||||
Returns a flat node/edge structure optimized for rendering with graph libraries.
|
||||
Only includes resolved relations (where to_id is not null).
|
||||
"""
|
||||
logger.info("API v2 request: get_graph")
|
||||
with logfire.span(
|
||||
"api.request.knowledge.get_graph",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="get_graph",
|
||||
):
|
||||
logger.info("API v2 request: get_graph")
|
||||
|
||||
# Fetch all entities for this project
|
||||
entities = await entity_repository.find_all(use_load_options=False)
|
||||
nodes = [
|
||||
GraphNode(
|
||||
external_id=entity.external_id,
|
||||
title=entity.title,
|
||||
note_type=entity.note_type,
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
for entity in entities
|
||||
]
|
||||
# Fetch all entities for this project
|
||||
entities = await entity_repository.find_all(use_load_options=False)
|
||||
nodes = [
|
||||
GraphNode(
|
||||
external_id=entity.external_id,
|
||||
title=entity.title,
|
||||
note_type=entity.note_type,
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
for entity in entities
|
||||
]
|
||||
|
||||
# Fetch all resolved relations (to_id is not null) with eager-loaded entities
|
||||
relations = await relation_repository.find_all()
|
||||
edges = [
|
||||
GraphEdge(
|
||||
from_id=relation.from_entity.external_id,
|
||||
to_id=relation.to_entity.external_id,
|
||||
relation_type=relation.relation_type,
|
||||
)
|
||||
for relation in relations
|
||||
if relation.to_entity is not None
|
||||
]
|
||||
# Fetch all resolved relations (to_id is not null) with eager-loaded entities
|
||||
relations = await relation_repository.find_all()
|
||||
edges = [
|
||||
GraphEdge(
|
||||
from_id=relation.from_entity.external_id,
|
||||
to_id=relation.to_entity.external_id,
|
||||
relation_type=relation.relation_type,
|
||||
)
|
||||
for relation in relations
|
||||
if relation.to_entity is not None
|
||||
]
|
||||
|
||||
logger.info(f"API v2 response: graph with {len(nodes)} nodes and {len(edges)} edges")
|
||||
return GraphResponse(nodes=nodes, edges=edges)
|
||||
logger.info(f"API v2 response: graph with {len(nodes)} nodes and {len(edges)} edges")
|
||||
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
|
||||
@@ -143,7 +181,7 @@ async def resolve_identifier(
|
||||
"resolution_method": "permalink"
|
||||
}
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.knowledge.resolve_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
@@ -151,25 +189,13 @@ async def resolve_identifier(
|
||||
):
|
||||
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.lookup_entity",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="lookup_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(data.identifier)
|
||||
entity = await entity_repository.get_by_external_id(data.identifier)
|
||||
resolution_method = "external_id" if entity else "search"
|
||||
|
||||
if not entity:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.resolve_link",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="resolve_link",
|
||||
):
|
||||
entity = await link_resolver.resolve_link(
|
||||
data.identifier, source_path=data.source_path, strict=data.strict
|
||||
)
|
||||
entity = await link_resolver.resolve_link(
|
||||
data.identifier, source_path=data.source_path, strict=data.strict
|
||||
)
|
||||
if entity:
|
||||
if entity.permalink == data.identifier:
|
||||
resolution_method = "permalink"
|
||||
@@ -183,20 +209,14 @@ async def resolve_identifier(
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.shape_response",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="shape_response",
|
||||
):
|
||||
result = EntityResolveResponse(
|
||||
external_id=entity.external_id,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
result = EntityResolveResponse(
|
||||
external_id=entity.external_id,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
|
||||
@@ -228,7 +248,7 @@ async def get_entity_by_id(
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.knowledge.get_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
@@ -236,25 +256,13 @@ async def get_entity_by_id(
|
||||
):
|
||||
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.get_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="get_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.get_entity.shape_response",
|
||||
domain="knowledge",
|
||||
action="get_entity",
|
||||
phase="shape_response",
|
||||
):
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
logger.info(f"API v2 response: external_id={entity_id}, title='{result.title}'")
|
||||
|
||||
return result
|
||||
@@ -267,106 +275,44 @@ async def get_entity_by_id(
|
||||
async def create_entity(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
data: Entity data to create
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Created entity with generated external_id (UUID) and file content
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.knowledge.create_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", note_type=data.note_type, title=data.title
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data)
|
||||
written_content = None
|
||||
search_content = None
|
||||
else:
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
# Note writes are now internally consistent before the response returns. We only leave
|
||||
# truly derived work, like semantic vectors, on the async scheduler.
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
await search_service.index_entity(entity, content=write_result.search_content)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
# The write service already returns the canonical markdown accepted for this request.
|
||||
result = result.model_copy(update={"content": write_result.content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
@@ -381,18 +327,13 @@ async def create_entity(
|
||||
async def update_entity_by_id(
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by external ID.
|
||||
|
||||
@@ -401,121 +342,52 @@ async def update_entity_by_id(
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Updated entity data
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity with file content
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.knowledge.update_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
existing = await entity_repository.get_by_external_id(entity_id)
|
||||
existing = await entity_repository.get_by_external_id(entity_id)
|
||||
created = existing is None
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data, external_id=entity_id)
|
||||
written_content = None
|
||||
search_content = None
|
||||
response.status_code = 200 if existing else 201
|
||||
else:
|
||||
if existing:
|
||||
write_result = await entity_service.update_entity_with_content(existing, data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
response.status_code = 200
|
||||
else:
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
if entity.external_id != entity_id:
|
||||
entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{"external_id": entity_id},
|
||||
)
|
||||
# external_id fixup only changes the DB row. The file content is unchanged,
|
||||
# so the markdown captured during the write remains valid downstream.
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Entity with external_id '{entity_id}' not found",
|
||||
)
|
||||
response.status_code = 201
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
resolve_relations=created,
|
||||
)
|
||||
if existing:
|
||||
write_result = await entity_service.update_entity_with_content(existing, data)
|
||||
entity = write_result.entity
|
||||
response.status_code = 200
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
if entity.external_id != entity_id:
|
||||
entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{"external_id": entity_id},
|
||||
)
|
||||
# external_id fixup only changes the DB row. The file content is unchanged,
|
||||
# so the markdown captured during the write remains valid downstream.
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Entity with external_id '{entity_id}' not found",
|
||||
)
|
||||
response.status_code = 201
|
||||
|
||||
await search_service.index_entity(entity, content=write_result.search_content)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
result = result.model_copy(update={"content": write_result.content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, created={created}, status_code={response.status_code}"
|
||||
@@ -526,25 +398,19 @@ async def update_entity_by_id(
|
||||
@router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def edit_entity_by_id(
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by external ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Edit operation details
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity with file content
|
||||
@@ -552,115 +418,43 @@ async def edit_entity_by_id(
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 if edit fails
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.knowledge.edit_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(
|
||||
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
try:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
updated_entity = await entity_service.fast_edit_entity(
|
||||
entity=entity,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
written_content = None
|
||||
search_content = None
|
||||
else:
|
||||
identifier = entity.permalink or entity.file_path
|
||||
write_result = await entity_service.edit_entity_with_content(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
updated_entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(updated_entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
identifier = entity.permalink or entity.file_path
|
||||
write_result = await entity_service.edit_entity_with_content(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
updated_entity = write_result.entity
|
||||
await search_service.index_entity(updated_entity, content=write_result.search_content)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(updated_entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
result = result.model_copy(update={"content": write_result.content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
@@ -678,12 +472,10 @@ async def edit_entity_by_id(
|
||||
|
||||
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity_by_id(
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
search_service=Depends(lambda: None), # Optional for now
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete an entity by external ID.
|
||||
|
||||
@@ -695,23 +487,25 @@ async def delete_entity_by_id(
|
||||
|
||||
Note: Returns deleted=False if entity doesn't exist (idempotent)
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
with logfire.span(
|
||||
"api.request.knowledge.delete_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="delete_entity",
|
||||
):
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: external_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: external_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity using internal ID
|
||||
deleted = await entity_service.delete_entity(entity.id)
|
||||
# Delete the entity using internal ID
|
||||
deleted = await entity_service.delete_entity(entity.id)
|
||||
|
||||
# Remove from search index if search service available
|
||||
if search_service:
|
||||
background_tasks.add_task(search_service.handle_delete, entity) # pragma: no cover
|
||||
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
|
||||
|
||||
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
|
||||
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
|
||||
|
||||
## Move endpoint
|
||||
@@ -720,7 +514,6 @@ async def delete_entity_by_id(
|
||||
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
|
||||
async def move_entity(
|
||||
data: MoveEntityRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
@@ -743,48 +536,58 @@ async def move_entity(
|
||||
Returns:
|
||||
Updated entity with new file path
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# First, get the entity by external_id to verify it exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
# Move the entity using its current file path as identifier
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=entity.file_path, # Use file path for resolution
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
with logfire.span(
|
||||
"api.request.knowledge.move_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="move_entity",
|
||||
):
|
||||
logger.info(
|
||||
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
try:
|
||||
# First, get the entity by external_id to verify it exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
# Move the entity using its current file path as identifier
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=entity.file_path, # Use file path for resolution
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
entity_id=reindexed_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(
|
||||
data.destination_path
|
||||
)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=reindexed_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
logger.info(f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'")
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
return result
|
||||
logger.info(
|
||||
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
|
||||
)
|
||||
|
||||
except HTTPException: # pragma: no cover
|
||||
raise # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return result
|
||||
|
||||
except HTTPException: # pragma: no cover
|
||||
raise # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Move directory endpoint
|
||||
@@ -793,7 +596,6 @@ async def move_entity(
|
||||
@router.post("/move-directory", response_model=DirectoryMoveResult)
|
||||
async def move_directory(
|
||||
data: MoveDirectoryRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
@@ -814,40 +616,46 @@ async def move_directory(
|
||||
Returns:
|
||||
DirectoryMoveResult with counts and details of moved files
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
with logfire.span(
|
||||
"api.request.knowledge.move_directory",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="move_directory",
|
||||
):
|
||||
logger.info(
|
||||
f"API v2 response: move_directory "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: move_directory "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete directory endpoint
|
||||
@@ -872,20 +680,26 @@ async def delete_directory(
|
||||
Returns:
|
||||
DirectoryDeleteResult with counts and details of deleted files
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
|
||||
with logfire.span(
|
||||
"api.request.knowledge.delete_directory",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="delete_directory",
|
||||
):
|
||||
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
|
||||
|
||||
try:
|
||||
# Delete the directory using the service
|
||||
result = await entity_service.delete_directory(
|
||||
directory=data.directory,
|
||||
)
|
||||
try:
|
||||
# Delete the directory using the service
|
||||
result = await entity_service.delete_directory(
|
||||
directory=data.directory,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: delete_directory "
|
||||
f"total={result.total_files}, success={result.successful_deletes}, failed={result.failed_deletes}"
|
||||
)
|
||||
return result
|
||||
logger.info(
|
||||
f"API v2 response: delete_directory "
|
||||
f"total={result.total_files}, success={result.successful_deletes}, failed={result.failed_deletes}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, Query, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
@@ -51,7 +51,7 @@ async def recent(
|
||||
Returns:
|
||||
GraphContext with recent activity and related entities
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.memory.recent_activity",
|
||||
entrypoint="api",
|
||||
domain="memory",
|
||||
@@ -72,7 +72,7 @@ async def recent(
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.memory.recent_activity.build_context",
|
||||
domain="memory",
|
||||
action="recent_activity",
|
||||
@@ -88,7 +88,7 @@ async def recent(
|
||||
offset=offset,
|
||||
max_related=max_related,
|
||||
)
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.memory.recent_activity.shape_response",
|
||||
domain="memory",
|
||||
action="recent_activity",
|
||||
@@ -137,7 +137,7 @@ async def get_memory_context(
|
||||
Returns:
|
||||
GraphContext with the entity and its related context
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.memory.build_context",
|
||||
entrypoint="api",
|
||||
domain="memory",
|
||||
@@ -154,7 +154,7 @@ async def get_memory_context(
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.memory.build_context.build_context",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
@@ -170,7 +170,7 @@ async def get_memory_context(
|
||||
offset=offset,
|
||||
max_related=max_related,
|
||||
)
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.memory.build_context.shape_response",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
|
||||
@@ -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}'")
|
||||
|
||||
@@ -6,6 +6,7 @@ have entity IDs in URLs - they generate formatted prompts from queries.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, status, Path
|
||||
from loguru import logger
|
||||
|
||||
@@ -59,6 +60,7 @@ async def continue_conversation(
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
hierarchical_results_for_count = []
|
||||
|
||||
# Get data needed for template
|
||||
if request.topic:
|
||||
@@ -91,7 +93,8 @@ async def continue_conversation(
|
||||
# Limit to a reasonable number of total results
|
||||
all_hierarchical_results = all_hierarchical_results[:10]
|
||||
|
||||
template_context = {
|
||||
hierarchical_results_for_count = all_hierarchical_results
|
||||
template_context: dict[str, Any] = {
|
||||
"topic": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": all_hierarchical_results,
|
||||
@@ -110,6 +113,7 @@ async def continue_conversation(
|
||||
|
||||
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
||||
|
||||
hierarchical_results_for_count = hierarchical_results
|
||||
template_context = {
|
||||
"topic": f"Recent Activity from ({request.timeframe})",
|
||||
"timeframe": request.timeframe,
|
||||
@@ -129,9 +133,6 @@ async def continue_conversation(
|
||||
relation_count = 0
|
||||
entity_count = 0
|
||||
|
||||
# Get the hierarchical results from the template context
|
||||
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
||||
|
||||
# For topic-based search
|
||||
if request.topic:
|
||||
for item in hierarchical_results_for_count:
|
||||
@@ -159,29 +160,24 @@ async def continue_conversation(
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results)
|
||||
if request.topic
|
||||
else 0, # Original search results count
|
||||
"context_count": len(hierarchical_results_for_count),
|
||||
"observation_count": observation_count,
|
||||
"relation_count": relation_count,
|
||||
"total_items": (
|
||||
prompt_metadata = PromptMetadata(
|
||||
query=request.topic,
|
||||
timeframe=request.timeframe,
|
||||
search_count=len(search_results) if request.topic else 0,
|
||||
context_count=len(hierarchical_results_for_count),
|
||||
observation_count=observation_count,
|
||||
relation_count=relation_count,
|
||||
total_items=(
|
||||
len(hierarchical_results_for_count)
|
||||
+ observation_count
|
||||
+ relation_count
|
||||
+ entity_count
|
||||
),
|
||||
"search_limit": request.search_items_limit,
|
||||
"context_depth": request.depth,
|
||||
"related_limit": request.related_items_limit,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
search_limit=request.search_items_limit,
|
||||
context_depth=request.depth,
|
||||
related_limit=request.related_items_limit,
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
@@ -229,7 +225,7 @@ async def search_prompt(
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
template_context = {
|
||||
template_context: dict[str, Any] = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"results": search_results,
|
||||
@@ -241,22 +237,19 @@ async def search_prompt(
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results),
|
||||
"context_count": len(search_results),
|
||||
"observation_count": 0, # Search results don't include observations
|
||||
"relation_count": 0, # Search results don't include relations
|
||||
"total_items": len(search_results),
|
||||
"search_limit": limit,
|
||||
"context_depth": 0, # No context depth for basic search
|
||||
"related_limit": 0, # No related items for basic search
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
prompt_metadata = PromptMetadata(
|
||||
query=request.query,
|
||||
timeframe=request.timeframe,
|
||||
search_count=len(search_results),
|
||||
context_count=len(search_results),
|
||||
observation_count=0,
|
||||
relation_count=0,
|
||||
total_items=len(search_results),
|
||||
search_limit=limit,
|
||||
context_depth=0,
|
||||
related_limit=0,
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
|
||||
@@ -15,7 +15,7 @@ from pathlib import Path as PathLib
|
||||
from fastapi import APIRouter, HTTPException, Response, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigV2ExternalDep,
|
||||
FileServiceV2ExternalDep,
|
||||
@@ -56,7 +56,7 @@ async def get_resource_content(
|
||||
Raises:
|
||||
HTTPException: 404 if entity or file not found
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.resource.get_content",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
@@ -64,7 +64,7 @@ async def get_resource_content(
|
||||
):
|
||||
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.get_content.load_entity",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
@@ -74,7 +74,7 @@ async def get_resource_content(
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.get_content.validate_path",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
@@ -90,7 +90,7 @@ async def get_resource_content(
|
||||
detail="Entity contains invalid file path",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.get_content.ensure_exists",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
@@ -102,7 +102,7 @@ async def get_resource_content(
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.get_content.read_content",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
@@ -139,7 +139,7 @@ async def create_resource(
|
||||
Raises:
|
||||
HTTPException: 400 for invalid file paths, 409 if file already exists
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.resource.create",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
@@ -166,7 +166,7 @@ async def create_resource(
|
||||
f"Use PUT /resource/{existing_entity.external_id} to update it.",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.create.write_file",
|
||||
domain="resource",
|
||||
action="create",
|
||||
@@ -175,7 +175,7 @@ async def create_resource(
|
||||
await file_service.ensure_directory(PathLib(data.file_path).parent)
|
||||
checksum = await file_service.write_file(data.file_path, data.content)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.create.read_metadata",
|
||||
domain="resource",
|
||||
action="create",
|
||||
@@ -197,7 +197,7 @@ async def create_resource(
|
||||
created_at=file_metadata.created_at,
|
||||
updated_at=file_metadata.modified_at,
|
||||
)
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.create.upsert_entity",
|
||||
domain="resource",
|
||||
action="create",
|
||||
@@ -205,13 +205,13 @@ async def create_resource(
|
||||
):
|
||||
entity = await entity_repository.add(entity)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.create.search_index",
|
||||
domain="resource",
|
||||
action="create",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
@@ -258,7 +258,7 @@ async def update_resource(
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 for invalid paths
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.resource.update",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
@@ -282,7 +282,7 @@ async def update_resource(
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.update.write_file",
|
||||
domain="resource",
|
||||
action="update",
|
||||
@@ -297,7 +297,7 @@ async def update_resource(
|
||||
|
||||
checksum = await file_service.write_file(target_file_path, data.content)
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.update.read_metadata",
|
||||
domain="resource",
|
||||
action="update",
|
||||
@@ -309,7 +309,7 @@ async def update_resource(
|
||||
content_type = file_service.content_type(target_file_path)
|
||||
note_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.update.update_entity",
|
||||
domain="resource",
|
||||
action="update",
|
||||
@@ -326,14 +326,16 @@ async def update_resource(
|
||||
"updated_at": file_metadata.modified_at,
|
||||
},
|
||||
)
|
||||
if updated_entity is None:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.resource.update.search_index",
|
||||
domain="resource",
|
||||
action="update",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(updated_entity) # pyright: ignore
|
||||
await search_service.index_entity(updated_entity)
|
||||
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
|
||||
@@ -4,15 +4,17 @@ 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
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.api.v2.utils import to_search_results
|
||||
from basic_memory.repository.semantic_errors import (
|
||||
SemanticDependenciesMissingError,
|
||||
SemanticSearchDisabledError,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse, SearchRetrievalMode
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
@@ -48,7 +50,7 @@ async def search(
|
||||
Returns:
|
||||
SearchResponse with paginated search results
|
||||
"""
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"api.request.search",
|
||||
entrypoint="api",
|
||||
domain="search",
|
||||
@@ -65,9 +67,9 @@ 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 telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.search.search.execute_query",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -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:
|
||||
@@ -83,18 +92,24 @@ async def search(
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.search.search.paginate_results",
|
||||
domain="search",
|
||||
action="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 telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.search.search.hydrate_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -102,7 +117,7 @@ async def search(
|
||||
result_count=len(results),
|
||||
):
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"api.search.search.build_response",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -113,6 +128,7 @@ async def search(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from typing import Optional, List
|
||||
from typing import Any, Protocol, Optional, List, Sequence
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.repository import EntityRepository
|
||||
import logfire
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import (
|
||||
EntitySummary,
|
||||
@@ -13,20 +11,39 @@ from basic_memory.schemas.memory import (
|
||||
ContextResult,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResult
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.services.context_service import (
|
||||
ContextResultRow,
|
||||
ContextResult as ServiceContextResult,
|
||||
)
|
||||
|
||||
|
||||
class EntityBatchLookup(Protocol):
|
||||
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Any]: ...
|
||||
|
||||
|
||||
class EntityServiceBatchLookup(Protocol):
|
||||
async def get_entities_by_id(self, ids: List[int]) -> Sequence[Any]: ...
|
||||
|
||||
|
||||
def _required_str(value: str | None, field_name: str) -> str:
|
||||
"""Return a required search field or fail before producing invalid response data."""
|
||||
if value is None:
|
||||
raise ValueError(f"Search result is missing required field: {field_name}")
|
||||
return value
|
||||
|
||||
|
||||
def _search_item_type(value: str | SearchItemType) -> SearchItemType:
|
||||
"""Normalize repository row type strings into the public search enum."""
|
||||
return value if isinstance(value, SearchItemType) else SearchItemType(value)
|
||||
|
||||
|
||||
async def to_graph_context(
|
||||
context_result: ServiceContextResult,
|
||||
entity_repository: EntityRepository,
|
||||
entity_repository: EntityBatchLookup,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
with telemetry.scope(
|
||||
) -> GraphContext:
|
||||
with logfire.span(
|
||||
"memory.hydrate_context",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
@@ -44,90 +61,96 @@ async def to_graph_context(
|
||||
+ context_item.observations
|
||||
+ context_item.related_results
|
||||
):
|
||||
if item.type == SearchItemType.ENTITY:
|
||||
item_type = _search_item_type(item.type)
|
||||
if item_type == SearchItemType.ENTITY:
|
||||
# Entity's own ID for its external_id
|
||||
entity_ids_needed.add(item.id)
|
||||
elif item.type == SearchItemType.OBSERVATION:
|
||||
elif item_type == SearchItemType.OBSERVATION:
|
||||
# Parent entity ID for entity_external_id
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.entity_id) # pyright: ignore
|
||||
elif item.type == SearchItemType.RELATION:
|
||||
if item.entity_id:
|
||||
entity_ids_needed.add(item.entity_id)
|
||||
elif item_type == SearchItemType.RELATION:
|
||||
# Source and target entity IDs for external_ids
|
||||
if item.from_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.from_id) # pyright: ignore
|
||||
if item.from_id:
|
||||
entity_ids_needed.add(item.from_id)
|
||||
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:
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"memory.hydrate_context.lookup_entities",
|
||||
domain="memory",
|
||||
action="build_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
|
||||
|
||||
# Helper function to convert items to summaries
|
||||
def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
def to_summary(
|
||||
item: SearchIndexRow | ContextResultRow,
|
||||
) -> EntitySummary | ObservationSummary | RelationSummary:
|
||||
item_type = _search_item_type(item.type)
|
||||
match item_type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
external_id=entity_external_id_lookup.get(item.id, ""),
|
||||
entity_id=item.id,
|
||||
title=item.title, # pyright: ignore
|
||||
title=_required_str(item.title, "title"),
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
file_path=item.file_path,
|
||||
file_path=_required_str(item.file_path, "file_path"),
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
entity_ext_id = None
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ext_id = entity_external_id_lookup.get(item.entity_id) # pyright: ignore
|
||||
entity_title = None
|
||||
if item.entity_id:
|
||||
entity_ext_id = entity_external_id_lookup.get(item.entity_id)
|
||||
entity_title = entity_title_lookup.get(item.entity_id)
|
||||
return ObservationSummary(
|
||||
observation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
entity_id=item.entity_id,
|
||||
entity_external_id=entity_ext_id,
|
||||
title=entity_title_lookup.get(item.entity_id), # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
title=entity_title,
|
||||
file_path=_required_str(item.file_path, "file_path"),
|
||||
category=_required_str(item.category, "category"),
|
||||
content=_required_str(item.content, "content"),
|
||||
permalink=_required_str(item.permalink, "permalink"),
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_title = entity_title_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
from_title = entity_title_lookup.get(item.from_id) if item.from_id else None
|
||||
to_title = entity_title_lookup.get(item.to_id) if item.to_id else None
|
||||
from_ext_id = (
|
||||
entity_external_id_lookup.get(item.from_id) if item.from_id else None
|
||||
) # pyright: ignore
|
||||
)
|
||||
to_ext_id = entity_external_id_lookup.get(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
relation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
entity_id=item.entity_id,
|
||||
title=_required_str(item.title, "title"),
|
||||
file_path=_required_str(item.file_path, "file_path"),
|
||||
permalink=_required_str(item.permalink, "permalink"),
|
||||
relation_type=_required_str(item.relation_type, "relation_type"),
|
||||
from_entity=from_title,
|
||||
from_entity_id=item.from_id, # pyright: ignore
|
||||
from_entity_id=item.from_id,
|
||||
from_entity_external_id=from_ext_id,
|
||||
to_entity=to_title,
|
||||
to_entity_id=item.to_id,
|
||||
to_entity_external_id=to_ext_id,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"memory.hydrate_context.shape_results",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
@@ -137,12 +160,16 @@ async def to_graph_context(
|
||||
hierarchical_results = []
|
||||
for context_item in context_result.results:
|
||||
primary_result = to_summary(context_item.primary_result)
|
||||
observations = [to_summary(obs) for obs in context_item.observations]
|
||||
observations = [
|
||||
summary
|
||||
for summary in (to_summary(obs) for obs in context_item.observations)
|
||||
if isinstance(summary, ObservationSummary)
|
||||
]
|
||||
related = [to_summary(rel) for rel in context_item.related_results]
|
||||
hierarchical_results.append(
|
||||
ContextResult(
|
||||
primary_result=primary_result,
|
||||
observations=observations, # pyright: ignore[reportArgumentType]
|
||||
observations=observations,
|
||||
related_results=related,
|
||||
)
|
||||
)
|
||||
@@ -170,8 +197,10 @@ async def to_graph_context(
|
||||
)
|
||||
|
||||
|
||||
async def to_search_results(entity_service: EntityService, results: List[SearchIndexRow]):
|
||||
with telemetry.scope(
|
||||
async def to_search_results(
|
||||
entity_service: EntityServiceBatchLookup, results: List[SearchIndexRow]
|
||||
) -> list[SearchResult]:
|
||||
with logfire.span(
|
||||
"search.hydrate_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -187,8 +216,8 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
all_entity_ids.add(eid)
|
||||
|
||||
# Single batch fetch for all entities
|
||||
entities_by_id: dict[int, EntityModel] = {}
|
||||
with telemetry.scope(
|
||||
entities_by_id: dict[int, Any] = {}
|
||||
with logfire.span(
|
||||
"search.hydrate_results.fetch_entities",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -200,7 +229,7 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
entities_by_id = {e.id: e for e in entities}
|
||||
|
||||
search_results = []
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"search.hydrate_results.shape_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
@@ -222,20 +251,20 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
entity_id = result.entity_id
|
||||
|
||||
# Look up entities by their specific IDs
|
||||
parent_entity = entities_by_id.get(result.entity_id) if result.entity_id else None # pyright: ignore
|
||||
from_entity = entities_by_id.get(result.from_id) if result.from_id else None # pyright: ignore
|
||||
parent_entity = entities_by_id.get(result.entity_id) if result.entity_id else None
|
||||
from_entity = entities_by_id.get(result.from_id) if result.from_id else None
|
||||
to_entity = entities_by_id.get(result.to_id) if result.to_id else None
|
||||
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=result.title, # pyright: ignore
|
||||
type=result.type, # pyright: ignore
|
||||
title=_required_str(result.title, "title"),
|
||||
type=_search_item_type(result.type),
|
||||
permalink=result.permalink,
|
||||
score=result.score, # pyright: ignore
|
||||
score=result.score if result.score is not None else 0.0,
|
||||
entity=parent_entity.permalink if parent_entity else None,
|
||||
content=result.content,
|
||||
matched_chunk=result.matched_chunk_text,
|
||||
file_path=result.file_path,
|
||||
file_path=_required_str(result.file_path, "file_path"),
|
||||
metadata=result.metadata,
|
||||
entity_id=entity_id,
|
||||
observation_id=observation_id,
|
||||
|
||||
@@ -12,7 +12,7 @@ from basic_memory.cli.auto_update import maybe_run_periodic_auto_update # noqa:
|
||||
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
|
||||
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
from basic_memory import telemetry # noqa: E402
|
||||
import logfire # noqa: E402
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -45,7 +45,7 @@ def app_callback(
|
||||
init_cli_logging()
|
||||
command_name = ctx.invoked_subcommand or "root"
|
||||
ctx.with_resource(
|
||||
telemetry.operation(
|
||||
logfire.span(
|
||||
f"cli.command.{command_name}",
|
||||
entrypoint="cli",
|
||||
command_name=command_name,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -99,41 +99,39 @@ async def make_api_request(
|
||||
response = await client.request(method=method, url=url, headers=headers, json=json_data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except httpx.HTTPStatusError as e:
|
||||
response = e.response
|
||||
|
||||
# Try to parse error detail from response
|
||||
error_detail = None
|
||||
try:
|
||||
error_detail = response.json()
|
||||
except Exception:
|
||||
# If JSON parsing fails, we'll handle it as a generic error
|
||||
pass
|
||||
|
||||
# Check for subscription_required error (403)
|
||||
if response.status_code == 403 and isinstance(error_detail, dict):
|
||||
# Handle both FastAPI HTTPException format (nested under "detail")
|
||||
# and direct format
|
||||
detail_obj = error_detail.get("detail", error_detail)
|
||||
if (
|
||||
isinstance(detail_obj, dict)
|
||||
and detail_obj.get("error") == "subscription_required"
|
||||
):
|
||||
message = detail_obj.get("message", "Active subscription required")
|
||||
subscribe_url = detail_obj.get(
|
||||
"subscribe_url", "https://basicmemory.com/subscribe"
|
||||
)
|
||||
raise SubscriptionRequiredError(
|
||||
message=message, subscribe_url=subscribe_url
|
||||
) from e
|
||||
|
||||
# Raise generic CloudAPIError with status code and detail
|
||||
raise CloudAPIError(
|
||||
f"API request failed: {e}",
|
||||
status_code=response.status_code,
|
||||
detail=error_detail if isinstance(error_detail, dict) else {},
|
||||
) from e
|
||||
except httpx.HTTPError as e:
|
||||
# Check if this is a response error with response details
|
||||
if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
|
||||
response = e.response # type: ignore
|
||||
|
||||
# Try to parse error detail from response
|
||||
error_detail = None
|
||||
try:
|
||||
error_detail = response.json()
|
||||
except Exception:
|
||||
# If JSON parsing fails, we'll handle it as a generic error
|
||||
pass
|
||||
|
||||
# Check for subscription_required error (403)
|
||||
if response.status_code == 403 and isinstance(error_detail, dict):
|
||||
# Handle both FastAPI HTTPException format (nested under "detail")
|
||||
# and direct format
|
||||
detail_obj = error_detail.get("detail", error_detail)
|
||||
if (
|
||||
isinstance(detail_obj, dict)
|
||||
and detail_obj.get("error") == "subscription_required"
|
||||
):
|
||||
message = detail_obj.get("message", "Active subscription required")
|
||||
subscribe_url = detail_obj.get(
|
||||
"subscribe_url", "https://basicmemory.com/subscribe"
|
||||
)
|
||||
raise SubscriptionRequiredError(
|
||||
message=message, subscribe_url=subscribe_url
|
||||
) from e
|
||||
|
||||
# Raise generic CloudAPIError with status code and detail
|
||||
raise CloudAPIError(
|
||||
f"API request failed: {e}",
|
||||
status_code=response.status_code,
|
||||
detail=error_detail if isinstance(error_detail, dict) else {},
|
||||
) from e
|
||||
|
||||
raise CloudAPIError(f"API request failed: {e}") from e
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -76,10 +76,22 @@ def login():
|
||||
|
||||
@cloud_app.command()
|
||||
def logout():
|
||||
"""Remove stored OAuth tokens."""
|
||||
config = ConfigManager().config
|
||||
"""Remove stored OAuth tokens and clear cached workspace selection."""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
auth.logout()
|
||||
|
||||
# Trigger: ending a session must invalidate the cached workspace.
|
||||
# Why: a follow-up `bm cloud login` (often as a different user, or returning
|
||||
# from an org workspace to personal) inherits the previous selection
|
||||
# and silently routes everything through the wrong tenant. See #755.
|
||||
# Outcome: re-login starts from a clean slate; the user picks again via
|
||||
# `bm cloud workspace set-default` or per-project --workspace.
|
||||
if config.default_workspace is not None:
|
||||
config.default_workspace = None
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]")
|
||||
|
||||
|
||||
@@ -167,7 +179,7 @@ def setup() -> None:
|
||||
console.print("1. Add a project with local sync path:")
|
||||
console.print(" bm project add research --cloud --local-path ~/Documents/research")
|
||||
console.print("\n Or configure sync for an existing project:")
|
||||
console.print(" bm project sync-setup research ~/Documents/research")
|
||||
console.print(" bm cloud sync-setup research ~/Documents/research")
|
||||
console.print("\n2. Preview the initial sync (recommended):")
|
||||
console.print(" bm project bisync --name research --resync --dry-run")
|
||||
console.print("\n3. If all looks good, run the actual sync:")
|
||||
|
||||
@@ -20,6 +20,7 @@ from loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
||||
from basic_memory.config import resolve_data_dir
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
console = Console()
|
||||
@@ -138,13 +139,16 @@ def get_bmignore_filter_path() -> Path:
|
||||
def get_project_bisync_state(project_name: str) -> Path:
|
||||
"""Get path to project's bisync state directory.
|
||||
|
||||
Honors ``BASIC_MEMORY_CONFIG_DIR`` so isolated instances each keep their
|
||||
own bisync state alongside their config.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
Path to bisync state directory for this project
|
||||
"""
|
||||
return Path.home() / ".basic-memory" / "bisync-state" / project_name
|
||||
return resolve_data_dir() / "bisync-state" / project_name
|
||||
|
||||
|
||||
def bisync_initialized(project_name: str) -> bool:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
import psutil
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -21,6 +23,103 @@ from basic_memory.sync.sync_service import get_sync_service
|
||||
console = Console()
|
||||
|
||||
|
||||
def _is_basic_memory_mcp(cmdline: list[str]) -> bool:
|
||||
"""Heuristic: does this argv represent a `basic-memory mcp` server?
|
||||
|
||||
The MCP server can be launched any of:
|
||||
basic-memory mcp
|
||||
bm mcp # entrypoint alias from pyproject.toml
|
||||
python -m basic_memory.cli.main mcp # module form
|
||||
uv run basic-memory mcp / uv run bm mcp # uv wrappers
|
||||
/abs/path/to/{bm,basic-memory}[.exe] mcp
|
||||
|
||||
A reliable match needs both signals:
|
||||
1. "mcp" appears as an exact argv token (not "mcp-foo").
|
||||
2. Some argv token names the basic-memory entrypoint — either by
|
||||
hyphen/underscore form, or as a `bm` script (covers `/usr/local/bin/bm`,
|
||||
`bm.exe`, etc. via Path.stem).
|
||||
"""
|
||||
if "mcp" not in cmdline:
|
||||
return False
|
||||
for arg in cmdline:
|
||||
if "basic-memory" in arg or "basic_memory" in arg:
|
||||
return True
|
||||
# Try both POSIX and Windows path interpretations so a test on
|
||||
# macOS still recognizes `C:\\...\\bm.exe`, and a real Windows
|
||||
# run still recognizes `/usr/local/bin/bm`. Path() alone uses
|
||||
# the host OS, which gives wrong stems for foreign separators.
|
||||
if PurePosixPath(arg).stem == "bm" or PureWindowsPath(arg).stem == "bm":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _find_live_mcp_processes() -> list[tuple[int, str]]:
|
||||
"""Return (pid, joined_cmdline) for live `basic-memory mcp` processes.
|
||||
|
||||
Why this exists (issue #765):
|
||||
On POSIX, `Path.unlink()` removes the directory entry but the inode
|
||||
survives as long as any process holds the file open. A `bm reset`
|
||||
run while Claude Desktop (or another MCP client) is alive will
|
||||
therefore "succeed" — but the still-running MCP keeps reading the
|
||||
old, now-invisible memory.db inode and returns phantom rows. On
|
||||
Windows the OS naturally raises PermissionError on `unlink()`, so
|
||||
the bug is POSIX-specific. We detect proactively to give the same
|
||||
error experience on every platform before doing damage.
|
||||
|
||||
The current process is excluded so this can be called from inside a
|
||||
`bm reset` invocation. NoSuchProcess / AccessDenied are swallowed
|
||||
because process tables race with the scan and we don't want a
|
||||
transient permission error to mask a real zombie.
|
||||
"""
|
||||
me = os.getpid()
|
||||
matches: list[tuple[int, str]] = []
|
||||
for proc in psutil.process_iter(["pid", "cmdline"]):
|
||||
try:
|
||||
pid = proc.info.get("pid")
|
||||
if pid is None or pid == me:
|
||||
continue
|
||||
cmdline = proc.info.get("cmdline") or []
|
||||
if not cmdline:
|
||||
continue
|
||||
if _is_basic_memory_mcp(cmdline):
|
||||
matches.append((pid, " ".join(cmdline)))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
return matches
|
||||
|
||||
|
||||
def _abort_if_mcp_processes_alive() -> None:
|
||||
"""Refuse `bm reset` while basic-memory MCP processes are still running.
|
||||
|
||||
See _find_live_mcp_processes for the underlying POSIX-vs-Windows
|
||||
rationale. Prints a per-PID list and platform-appropriate cleanup
|
||||
instructions, then exits non-zero so destructive work never starts.
|
||||
"""
|
||||
zombies = _find_live_mcp_processes()
|
||||
if not zombies:
|
||||
return
|
||||
|
||||
console.print("[red]Refusing to reset:[/red] basic-memory MCP processes are still running.")
|
||||
console.print(
|
||||
"[yellow]On macOS/Linux these would keep reading the deleted memory.db inode "
|
||||
"and return phantom search results (see #765).[/yellow]"
|
||||
)
|
||||
for pid, cmd in zombies:
|
||||
console.print(f" PID {pid}: {cmd}")
|
||||
console.print("\n[bold]How to clean up:[/bold]")
|
||||
console.print(" 1. Quit Claude Desktop and any other MCP clients.")
|
||||
if os.name == "nt":
|
||||
console.print(
|
||||
" 2. Verify nothing remains: "
|
||||
"[green]Get-CimInstance Win32_Process | "
|
||||
"Where-Object {$_.CommandLine -like '*basic-memory*mcp*'}[/green]"
|
||||
)
|
||||
else:
|
||||
console.print(" 2. Verify nothing remains: [green]pgrep -fa 'basic-memory mcp'[/green]")
|
||||
console.print(" 3. Re-run [green]bm reset[/green].")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EmbeddingProgress:
|
||||
"""Typed CLI progress payload for embedding backfills."""
|
||||
@@ -86,6 +185,16 @@ async def _reindex_projects(app_config):
|
||||
@app.command()
|
||||
def reset(
|
||||
reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
help=(
|
||||
"Skip the pre-flight check that refuses to reset while "
|
||||
"basic-memory MCP processes are running. Use only in "
|
||||
"automated workflows where you've already ensured no MCP "
|
||||
"clients are attached to the database."
|
||||
),
|
||||
),
|
||||
): # pragma: no cover
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
console.print(
|
||||
@@ -94,6 +203,14 @@ def reset(
|
||||
"Use [green]bm reset --reindex[/green] to automatically rebuild the index afterward."
|
||||
)
|
||||
if typer.confirm("Reset the database index?"):
|
||||
# Pre-flight: refuse to proceed if MCP processes still hold the DB
|
||||
# file open. POSIX would silently let us unlink the inode while
|
||||
# they keep reading it; Windows would error here anyway. See
|
||||
# _find_live_mcp_processes for the full story. --force is the
|
||||
# documented escape hatch for scripted/CI runs.
|
||||
if not force:
|
||||
_abort_if_mcp_processes_alive()
|
||||
|
||||
logger.info("Resetting database...")
|
||||
config_manager = ConfigManager()
|
||||
app_config = config_manager.config
|
||||
|
||||
@@ -69,7 +69,7 @@ async def run_doctor() -> None:
|
||||
content=f"# {api_note_title}\n\n- [note] API to file check",
|
||||
entity_metadata={"tags": ["doctor"]},
|
||||
)
|
||||
api_result = await knowledge_client.create_entity(api_note.model_dump(), fast=False)
|
||||
api_result = await knowledge_client.create_entity(api_note.model_dump())
|
||||
|
||||
api_file = project_path / api_result.file_path
|
||||
if not api_file.exists():
|
||||
|
||||
@@ -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
|
||||
@@ -26,13 +27,17 @@ from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
)
|
||||
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry, ProjectMode
|
||||
from basic_memory.mcp.async_client import get_client, resolve_configured_workspace
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
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,
|
||||
@@ -838,13 +1020,83 @@ def move_project(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
async def _detach_local_project_row(app_config: BasicMemoryConfig, name: str) -> bool:
|
||||
"""Drop the project's row from the local index DB.
|
||||
|
||||
Trigger: `bm project set-cloud` is making a project cloud-only.
|
||||
Why: the local row is what causes `_merge_projects` to report
|
||||
`source: "local+cloud"` after the toggle (#680). Removing it
|
||||
forces the merged listing to honor the user's chosen mode.
|
||||
Outcome: returns True if a row was deleted, False if there was
|
||||
nothing to clean up. On-disk note files are not touched.
|
||||
"""
|
||||
from basic_memory import db
|
||||
from basic_memory.repository import ProjectRepository
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
)
|
||||
try:
|
||||
repo = ProjectRepository(session_maker)
|
||||
existing = await repo.get_by_name(name)
|
||||
if existing is None:
|
||||
return False
|
||||
await repo.delete(existing.id)
|
||||
return True
|
||||
finally:
|
||||
# CLI-only: safe to tear down the global DB singleton here since
|
||||
# set-cloud/set-local never run inside a long-lived MCP/API server.
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
async def _attach_local_project_row(app_config: BasicMemoryConfig, name: str, path: str) -> None:
|
||||
"""Ensure the project has a row in the local index DB at the given path.
|
||||
|
||||
Trigger: `bm project set-local` is making a previously cloud-only
|
||||
project local again.
|
||||
Why: without a row in the local DB, every local-side tool (`list`,
|
||||
`info`, sync, indexing) would skip this project.
|
||||
Outcome: a row is created if missing, or its path is updated to match
|
||||
the new local home if it already exists. On-disk files are not
|
||||
touched — the caller is responsible for ensuring the directory
|
||||
exists.
|
||||
"""
|
||||
from basic_memory import db
|
||||
from basic_memory.repository import ProjectRepository
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
)
|
||||
try:
|
||||
repo = ProjectRepository(session_maker)
|
||||
existing = await repo.get_by_name(name)
|
||||
if existing is None:
|
||||
await repo.create(
|
||||
{
|
||||
"name": name,
|
||||
"path": path,
|
||||
"permalink": generate_permalink(name),
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
return
|
||||
if existing.path != path:
|
||||
await repo.update_path(existing.id, path)
|
||||
finally:
|
||||
# CLI-only: safe to tear down the global DB singleton here since
|
||||
# set-cloud/set-local never run inside a long-lived MCP/API server.
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
@project_app.command("set-cloud")
|
||||
def set_cloud(
|
||||
name: str = typer.Argument(..., help="Name of the project to route through 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).
|
||||
@@ -855,6 +1107,13 @@ def set_cloud(
|
||||
If omitted, uses the default workspace (if set) or auto-selects when
|
||||
only one workspace is available.
|
||||
|
||||
This is a one-way cutover: the project's row in the local index DB is
|
||||
removed and the local path in config is cleared so the project's
|
||||
configured state is purely cloud. On-disk note files are preserved —
|
||||
the caller can keep, archive, or delete them as they see fit. To
|
||||
return to local mode use `bm project set-local <name> --local-path
|
||||
<path>`.
|
||||
|
||||
Examples:
|
||||
bm project set-cloud research --workspace Personal
|
||||
bm project set-cloud research --workspace 11111111-...
|
||||
@@ -883,27 +1142,52 @@ def set_cloud(
|
||||
|
||||
resolved_workspace_id = _resolve_workspace_id(config, workspace)
|
||||
|
||||
# Drop the local DB row first so the user-visible state stays consistent
|
||||
# even if the config save below raises for some reason. Idempotent: a
|
||||
# second `set-cloud` simply finds no row and returns False.
|
||||
previous_path = config.projects[name].path
|
||||
detached = run_with_cleanup(_detach_local_project_row(config, name))
|
||||
|
||||
config.set_project_mode(name, ProjectMode.CLOUD)
|
||||
if resolved_workspace_id:
|
||||
config.projects[name].workspace_id = resolved_workspace_id
|
||||
# Clear local path: source-of-truth for this project is now the cloud
|
||||
config.projects[name].path = ""
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Project '{name}' set to cloud mode[/green]")
|
||||
if resolved_workspace_id:
|
||||
console.print(f"[dim]Workspace: {resolved_workspace_id}[/dim]")
|
||||
if detached and previous_path:
|
||||
console.print(
|
||||
f"[dim]Local index entry removed. Files at {previous_path} are preserved on disk.[/dim]"
|
||||
)
|
||||
console.print("[dim]MCP tools and CLI commands for this project will route through cloud[/dim]")
|
||||
|
||||
|
||||
@project_app.command("set-local")
|
||||
def set_local(
|
||||
name: str = typer.Argument(..., help="Name of the project to revert to local mode"),
|
||||
local_path: str = typer.Option(
|
||||
None,
|
||||
"--local-path",
|
||||
help=(
|
||||
"Local filesystem path for this project. Required unless the project "
|
||||
"was previously local and its prior path is still in config."
|
||||
),
|
||||
),
|
||||
) -> None:
|
||||
"""Revert a project to local mode (use in-process ASGI transport).
|
||||
|
||||
Clears any associated cloud workspace.
|
||||
Recreates the project's row in the local index DB and clears any
|
||||
associated cloud workspace. If the project was previously local and
|
||||
its prior path is still in config (e.g. an older version that didn't
|
||||
blank `path` on `set-cloud`), `--local-path` may be omitted and that
|
||||
path will be reused.
|
||||
|
||||
Example:
|
||||
bm project set-local research
|
||||
Examples:
|
||||
bm project set-local research --local-path ~/Documents/research
|
||||
bm project set-local research # reuse prior path
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
@@ -913,11 +1197,31 @@ def set_local(
|
||||
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
entry = config.projects[name]
|
||||
candidate = local_path or entry.path
|
||||
if not candidate:
|
||||
console.print(
|
||||
f"[red]Error: --local-path is required for '{name}' "
|
||||
"(no previous local path is recorded)[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(candidate))).as_posix()
|
||||
|
||||
# Recreate the local DB row. Idempotent: if the row exists with the
|
||||
# same path it's a no-op; if it exists at a stale path the path is
|
||||
# updated. The directory itself is not auto-created — the user is
|
||||
# expected to know whether they want to start a fresh project tree
|
||||
# or point at an existing one.
|
||||
run_with_cleanup(_attach_local_project_row(config, name, resolved_path))
|
||||
|
||||
config.set_project_mode(name, ProjectMode.LOCAL)
|
||||
config.projects[name].workspace_id = None
|
||||
config.projects[name].path = resolved_path
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Project '{name}' set to local mode[/green]")
|
||||
console.print(f"[dim]Path: {resolved_path}[/dim]")
|
||||
console.print("[dim]MCP tools and CLI commands for this project will use local transport[/dim]")
|
||||
|
||||
|
||||
|
||||
@@ -62,9 +62,12 @@ def write_note(
|
||||
help="The project to write to. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -106,7 +109,7 @@ def write_note(
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
tags=tags,
|
||||
output_format="json",
|
||||
)
|
||||
@@ -128,15 +131,16 @@ def read_note(
|
||||
include_frontmatter: bool = typer.Option(
|
||||
False, "--include-frontmatter", help="Include YAML frontmatter in output"
|
||||
),
|
||||
page: int = typer.Option(1, "--page", help="Page number for pagination"),
|
||||
page_size: int = typer.Option(10, "--page-size", help="Number of results per page"),
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -149,7 +153,6 @@ def read_note(
|
||||
|
||||
bm tool read-note my-note
|
||||
bm tool read-note my-note --include-frontmatter
|
||||
bm tool read-note my-note --page 2 --page-size 5
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
@@ -159,9 +162,7 @@ def read_note(
|
||||
mcp_read_note(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
project_id=project_id,
|
||||
include_frontmatter=include_frontmatter,
|
||||
output_format="json",
|
||||
)
|
||||
@@ -200,9 +201,12 @@ def edit_note(
|
||||
help="The project to edit. If not provided, the default project will be used."
|
||||
),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -227,7 +231,7 @@ def edit_note(
|
||||
operation=operation,
|
||||
content=content,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
section=section,
|
||||
find_text=find_text,
|
||||
expected_replacements=expected_replacements,
|
||||
@@ -265,9 +269,12 @@ def build_context(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -289,7 +296,7 @@ def build_context(
|
||||
mcp_build_context(
|
||||
url=url,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
@@ -322,9 +329,12 @@ def recent_activity(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -345,13 +355,13 @@ def recent_activity(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity(
|
||||
type=type, # pyright: ignore[reportArgumentType]
|
||||
type=type or "",
|
||||
depth=depth if depth is not None else 1,
|
||||
timeframe=timeframe if timeframe is not None else "7d",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
@@ -413,9 +423,12 @@ def search_notes(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -487,7 +500,7 @@ def search_notes(
|
||||
mcp_search(
|
||||
query=query or None,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
search_type=search_type,
|
||||
output_format="json",
|
||||
page=page,
|
||||
@@ -597,9 +610,12 @@ def schema_validate(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -634,7 +650,7 @@ def schema_validate(
|
||||
note_type=note_type,
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
@@ -665,9 +681,12 @@ def schema_infer(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -691,7 +710,7 @@ def schema_infer(
|
||||
note_type=note_type,
|
||||
threshold=threshold,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
@@ -719,9 +738,12 @@ def schema_diff(
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
workspace: Annotated[
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
@@ -743,7 +765,7 @@ def schema_diff(
|
||||
mcp_schema_diff(
|
||||
note_type=note_type,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
import_claude_projects,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
orphans,
|
||||
project,
|
||||
schema,
|
||||
status,
|
||||
|
||||
+90
-25
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional, List, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, List, Tuple
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import AliasChoices, BaseModel, Field, model_validator
|
||||
@@ -21,13 +21,27 @@ from basic_memory.utils import setup_logging, generate_permalink
|
||||
|
||||
DATABASE_NAME = "memory.db"
|
||||
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
|
||||
DATA_DIR_NAME = ".basic-memory"
|
||||
DATA_DIR_NAME = "basic-memory"
|
||||
CONFIG_FILE_NAME = "config.json"
|
||||
WATCH_STATUS_JSON = "watch-status.json"
|
||||
CONFIG_DIR_MODE = 0o700
|
||||
CONFIG_FILE_MODE = 0o600
|
||||
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
def _secure_config_dir(path: Path) -> None:
|
||||
"""Restrict config directory permissions on platforms with POSIX modes."""
|
||||
if os.name != "nt":
|
||||
path.chmod(CONFIG_DIR_MODE)
|
||||
|
||||
|
||||
def _secure_config_file(path: Path) -> None:
|
||||
"""Restrict config file permissions because config can contain cloud credentials."""
|
||||
if os.name != "nt":
|
||||
path.chmod(CONFIG_FILE_MODE)
|
||||
|
||||
|
||||
class ProjectMode(str, Enum):
|
||||
"""Per-project routing mode."""
|
||||
|
||||
@@ -50,6 +64,47 @@ def _default_semantic_search_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def resolve_data_dir() -> Path:
|
||||
"""Resolve the Basic Memory data directory.
|
||||
|
||||
Single source of truth for the per-user state directory. Honors
|
||||
``BASIC_MEMORY_CONFIG_DIR`` so each process/worktree can isolate config
|
||||
and database state; otherwise falls back to ``<user home>/.basic-memory``,
|
||||
and then to ``XDG_CONFIG_HOME``.
|
||||
|
||||
Cross-platform: ``Path.home()`` reads ``$HOME`` on POSIX and
|
||||
``%USERPROFILE%`` on Windows, so there's no need to check ``$HOME``
|
||||
explicitly here.
|
||||
"""
|
||||
if basic_memory_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
||||
return Path(basic_memory_dir)
|
||||
if xdg_config := os.getenv("XDG_CONFIG_HOME"):
|
||||
return Path(xdg_config) / DATA_DIR_NAME
|
||||
return Path.home() / ("." + DATA_DIR_NAME)
|
||||
|
||||
|
||||
def default_fastembed_cache_dir() -> str:
|
||||
"""Return the default cache directory used for FastEmbed model artifacts.
|
||||
|
||||
Resolution order:
|
||||
1. ``FASTEMBED_CACHE_PATH`` env var — honors FastEmbed's own convention
|
||||
so users who already configure it through the environment keep working.
|
||||
2. ``<basic-memory data dir>/fastembed_cache`` — the same stable,
|
||||
user-writable directory Basic Memory already uses for config and
|
||||
the default SQLite database. Honors ``BASIC_MEMORY_CONFIG_DIR``.
|
||||
|
||||
Why not ``tempfile.gettempdir()``?
|
||||
FastEmbed's own default is ``<system tmp>/fastembed_cache``, which is
|
||||
ephemeral in many sandboxed MCP runtimes (e.g. Codex CLI wipes /tmp
|
||||
between invocations). The model then disappears and every subsequent
|
||||
ONNX load raises ``NO_SUCHFILE``. Persisting the cache under the
|
||||
per-user data directory works identically on macOS, Linux, and Windows.
|
||||
"""
|
||||
if env_override := os.getenv("FASTEMBED_CACHE_PATH"):
|
||||
return env_override
|
||||
return str(resolve_data_dir() / "fastembed_cache")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
@@ -122,16 +177,23 @@ class ProjectEntry(BaseModel):
|
||||
class BasicMemoryConfig(BaseSettings):
|
||||
"""Pydantic model for Basic Memory global configuration."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Pydantic accepts raw constructor data and validates/coerces it at runtime.
|
||||
# Model attributes remain strongly typed after initialization.
|
||||
def __init__(self, **data: Any) -> None: ...
|
||||
|
||||
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(
|
||||
@@ -217,7 +279,13 @@ class BasicMemoryConfig(BaseSettings):
|
||||
)
|
||||
semantic_embedding_cache_dir: str | None = Field(
|
||||
default=None,
|
||||
description="Optional cache directory for FastEmbed model artifacts.",
|
||||
description=(
|
||||
"Optional override for the FastEmbed model cache directory. "
|
||||
"When unset, Basic Memory resolves this at runtime to "
|
||||
"<basic-memory data dir>/fastembed_cache (or FASTEMBED_CACHE_PATH "
|
||||
"when that env var is set) so the model persists across runs "
|
||||
"without hardcoding a path into config.json."
|
||||
),
|
||||
)
|
||||
semantic_embedding_threads: int | None = Field(
|
||||
default=None,
|
||||
@@ -229,6 +297,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.",
|
||||
@@ -704,11 +777,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
@property
|
||||
def data_dir_path(self) -> Path:
|
||||
"""Get app state directory for config and default SQLite database."""
|
||||
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
||||
return Path(config_dir)
|
||||
|
||||
home = os.getenv("HOME", Path.home())
|
||||
return Path(home) / DATA_DIR_NAME
|
||||
return resolve_data_dir()
|
||||
|
||||
|
||||
# Module-level cache for configuration
|
||||
@@ -726,20 +795,12 @@ class ConfigManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the configuration manager."""
|
||||
home = os.getenv("HOME", Path.home())
|
||||
if isinstance(home, str):
|
||||
home = Path(home)
|
||||
|
||||
# Allow override via environment variable
|
||||
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
||||
self.config_dir = Path(config_dir)
|
||||
else:
|
||||
self.config_dir = home / DATA_DIR_NAME
|
||||
|
||||
self.config_dir = resolve_data_dir()
|
||||
self.config_file = self.config_dir / CONFIG_FILE_NAME
|
||||
|
||||
# Ensure config directory exists
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
_secure_config_dir(self.config_dir)
|
||||
|
||||
@property
|
||||
def config(self) -> BasicMemoryConfig:
|
||||
@@ -852,6 +913,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)
|
||||
|
||||
@@ -1007,9 +1069,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}")
|
||||
|
||||
|
||||
@@ -447,8 +447,12 @@ class TaskScheduler(Protocol):
|
||||
|
||||
|
||||
def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
if completed.cancelled():
|
||||
return
|
||||
try:
|
||||
completed.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Background task failed", error=str(exc))
|
||||
|
||||
@@ -492,7 +496,6 @@ class LocalTaskScheduler:
|
||||
|
||||
|
||||
async def get_task_scheduler(
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
@@ -500,28 +503,6 @@ async def get_task_scheduler(
|
||||
) -> TaskScheduler:
|
||||
"""Create a scheduler that maps task specs to coroutines."""
|
||||
|
||||
scheduler: LocalTaskScheduler | None = None
|
||||
|
||||
async def _reindex_entity(
|
||||
entity_id: int,
|
||||
resolve_relations: bool = False,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
await entity_service.reindex_entity(entity_id)
|
||||
# Trigger: caller requests relation resolution
|
||||
# Why: resolve forward references created before the entity existed
|
||||
# Outcome: updates unresolved relations pointing to this entity
|
||||
if resolve_relations:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
# Trigger: semantic search enabled in local config.
|
||||
# Why: vector chunks are derived and should refresh after canonical reindex completes.
|
||||
# Outcome: schedules out-of-band vector sync without extending write latency.
|
||||
if app_config.semantic_search_enabled and scheduler is not None:
|
||||
scheduler.schedule("sync_entity_vectors", entity_id=entity_id)
|
||||
|
||||
async def _resolve_relations(entity_id: int, **_: Any) -> None:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
|
||||
async def _sync_entity_vectors(entity_id: int, **_: Any) -> None:
|
||||
await search_service.sync_entity_vectors(entity_id)
|
||||
|
||||
@@ -537,8 +518,6 @@ async def get_task_scheduler(
|
||||
|
||||
scheduler = LocalTaskScheduler(
|
||||
{
|
||||
"reindex_entity": _reindex_entity,
|
||||
"resolve_relations": _resolve_relations,
|
||||
"sync_entity_vectors": _sync_entity_vectors,
|
||||
"sync_project": _sync_project,
|
||||
"reindex_project": _reindex_project,
|
||||
|
||||
@@ -4,6 +4,8 @@ import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from basic_memory.config import resolve_data_dir
|
||||
|
||||
|
||||
# Common directories and patterns to ignore by default
|
||||
# These are used as fallback if .bmignore doesn't exist
|
||||
@@ -61,9 +63,11 @@ def get_bmignore_path() -> Path:
|
||||
"""Get path to .bmignore file.
|
||||
|
||||
Returns:
|
||||
Path to ~/.basic-memory/.bmignore
|
||||
Path to <basic-memory data dir>/.bmignore, honoring
|
||||
``BASIC_MEMORY_CONFIG_DIR`` so isolated instances each keep their
|
||||
own ignore file.
|
||||
"""
|
||||
return Path.home() / ".basic-memory" / ".bmignore"
|
||||
return resolve_data_dir() / ".bmignore"
|
||||
|
||||
|
||||
def create_default_bmignore() -> None:
|
||||
@@ -176,7 +180,8 @@ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[
|
||||
"""Load gitignore patterns from .gitignore file and .bmignore.
|
||||
|
||||
Combines patterns from:
|
||||
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
|
||||
1. <basic-memory data dir>/.bmignore (user's global ignore patterns, honors
|
||||
BASIC_MEMORY_CONFIG_DIR)
|
||||
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
|
||||
|
||||
Args:
|
||||
|
||||
@@ -39,23 +39,24 @@ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
|
||||
Returns:
|
||||
A formatted string representation of the timestamp.
|
||||
"""
|
||||
parsed_timestamp = timestamp
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
# Try ISO format
|
||||
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
try:
|
||||
# Try unix timestamp as string
|
||||
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
|
||||
parsed_timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
|
||||
except ValueError:
|
||||
# Return as is if we can't parse it
|
||||
return timestamp
|
||||
elif isinstance(timestamp, (int, float)):
|
||||
# Unix timestamp
|
||||
timestamp = datetime.fromtimestamp(timestamp).astimezone()
|
||||
parsed_timestamp = datetime.fromtimestamp(timestamp).astimezone()
|
||||
|
||||
if isinstance(timestamp, datetime):
|
||||
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if isinstance(parsed_timestamp, datetime):
|
||||
return parsed_timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Return as is if we can't format it
|
||||
return str(timestamp) # pragma: no cover
|
||||
return str(parsed_timestamp) # pragma: no cover
|
||||
|
||||
@@ -12,6 +12,7 @@ from basic_memory.indexing.models import (
|
||||
IndexingBatchResult,
|
||||
IndexInputFile,
|
||||
IndexProgress,
|
||||
SyncedMarkdownFile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -25,5 +26,6 @@ __all__ = [
|
||||
"IndexingBatchResult",
|
||||
"IndexInputFile",
|
||||
"IndexProgress",
|
||||
"SyncedMarkdownFile",
|
||||
"build_index_batches",
|
||||
]
|
||||
|
||||
@@ -11,8 +11,9 @@ from typing import Awaitable, Callable, Mapping, TypeVar
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
import logfire
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.file_utils import compute_checksum, has_frontmatter
|
||||
from basic_memory.file_utils import compute_checksum, has_frontmatter, remove_frontmatter
|
||||
from basic_memory.markdown.schemas import EntityMarkdown
|
||||
from basic_memory.indexing.models import (
|
||||
IndexedEntity,
|
||||
@@ -43,12 +44,19 @@ class _PreparedMarkdownFile:
|
||||
class _PreparedEntity:
|
||||
path: str
|
||||
entity_id: int
|
||||
permalink: str | None
|
||||
checksum: str
|
||||
content_type: str | None
|
||||
search_content: str | None
|
||||
markdown_content: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PersistedMarkdownFile:
|
||||
prepared: _PreparedMarkdownFile
|
||||
entity: Entity
|
||||
|
||||
|
||||
class BatchIndexer:
|
||||
"""Index already-loaded files without assuming where they came from."""
|
||||
|
||||
@@ -118,6 +126,9 @@ class BatchIndexer:
|
||||
)
|
||||
error_by_path.update(markdown_errors)
|
||||
prepared_entities.update(markdown_upserts)
|
||||
if existing_permalink_by_path is not None:
|
||||
for path, prepared_entity in markdown_upserts.items():
|
||||
existing_permalink_by_path[path] = prepared_entity.permalink
|
||||
|
||||
regular_upserts, regular_errors = await self._run_bounded(
|
||||
regular_paths,
|
||||
@@ -168,6 +179,76 @@ class BatchIndexer:
|
||||
search_indexed=search_indexed,
|
||||
)
|
||||
|
||||
async def index_markdown_file(
|
||||
self,
|
||||
file: IndexInputFile,
|
||||
*,
|
||||
new: bool | None = None,
|
||||
existing_permalink_by_path: dict[str, str | None] | None = None,
|
||||
index_search: bool = True,
|
||||
resolve_relations: bool = True,
|
||||
) -> IndexedEntity:
|
||||
"""Index one markdown file using the same normalization and upsert path as batches."""
|
||||
if not self._is_markdown(file):
|
||||
raise ValueError(f"index_markdown_file requires markdown input: {file.path}")
|
||||
|
||||
with logfire.span("index.markdown_file.prepare", path=file.path):
|
||||
prepared = await self._prepare_markdown_file(file)
|
||||
if existing_permalink_by_path is None:
|
||||
with logfire.span("index.markdown_file.load_permalink_map", path=file.path):
|
||||
existing_permalink_by_path = {
|
||||
path: permalink
|
||||
for path, permalink in (
|
||||
await self.entity_repository.get_file_path_to_permalink_map()
|
||||
).items()
|
||||
}
|
||||
|
||||
reserved_permalinks = {
|
||||
permalink
|
||||
for path, permalink in existing_permalink_by_path.items()
|
||||
if path != file.path and permalink
|
||||
}
|
||||
with logfire.span("index.markdown_file.normalize", path=file.path):
|
||||
prepared = await self._normalize_markdown_file(prepared, reserved_permalinks)
|
||||
existing_permalink_by_path[file.path] = prepared.markdown.frontmatter.permalink
|
||||
|
||||
with logfire.span("index.markdown_file.persist", path=file.path, is_new=new):
|
||||
persisted = await self._persist_markdown_file(
|
||||
prepared,
|
||||
is_new=new,
|
||||
resolve_relations=resolve_relations,
|
||||
reload_entity=False,
|
||||
)
|
||||
existing_permalink_by_path[file.path] = persisted.entity.permalink
|
||||
|
||||
with logfire.span(
|
||||
"index.markdown_file.reload_entity",
|
||||
path=file.path,
|
||||
entity_id=persisted.entity.id,
|
||||
):
|
||||
refreshed = await self.entity_repository.find_by_ids([persisted.entity.id])
|
||||
if len(refreshed) != 1: # pragma: no cover
|
||||
raise ValueError(f"Failed to reload indexed entity for {file.path}")
|
||||
entity = refreshed[0]
|
||||
prepared_entity = self._build_prepared_entity(persisted.prepared, entity)
|
||||
|
||||
if index_search:
|
||||
with logfire.span(
|
||||
"index.markdown_file.refresh_search_index",
|
||||
path=file.path,
|
||||
entity_id=entity.id,
|
||||
):
|
||||
return await self._refresh_search_index(prepared_entity, entity)
|
||||
|
||||
return IndexedEntity(
|
||||
path=prepared_entity.path,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
checksum=prepared_entity.checksum,
|
||||
content_type=prepared_entity.content_type,
|
||||
markdown_content=prepared_entity.markdown_content,
|
||||
)
|
||||
|
||||
# --- Preparation ---
|
||||
|
||||
async def _prepare_markdown_file(self, file: IndexInputFile) -> _PreparedMarkdownFile:
|
||||
@@ -320,34 +401,8 @@ class BatchIndexer:
|
||||
# --- Persistence ---
|
||||
|
||||
async def _upsert_markdown_file(self, prepared: _PreparedMarkdownFile) -> _PreparedEntity:
|
||||
existing = await self.entity_repository.get_by_file_path(
|
||||
prepared.file.path,
|
||||
load_relations=False,
|
||||
)
|
||||
entity = await self.entity_service.upsert_entity_from_markdown(
|
||||
Path(prepared.file.path),
|
||||
prepared.markdown,
|
||||
is_new=existing is None,
|
||||
)
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id,
|
||||
self._entity_metadata_updates(prepared.file, prepared.final_checksum),
|
||||
)
|
||||
if updated is None:
|
||||
raise ValueError(f"Failed to update markdown entity metadata for {prepared.file.path}")
|
||||
|
||||
return _PreparedEntity(
|
||||
path=prepared.file.path,
|
||||
entity_id=updated.id,
|
||||
checksum=prepared.final_checksum,
|
||||
content_type=prepared.file.content_type,
|
||||
search_content=(
|
||||
prepared.markdown.content
|
||||
if prepared.markdown.content is not None
|
||||
else prepared.content
|
||||
),
|
||||
markdown_content=prepared.content,
|
||||
)
|
||||
persisted = await self._persist_markdown_file(prepared)
|
||||
return self._build_prepared_entity(persisted.prepared, persisted.entity)
|
||||
|
||||
async def _upsert_regular_file(self, file: IndexInputFile) -> _PreparedEntity:
|
||||
checksum = await self._resolve_checksum(file)
|
||||
@@ -405,6 +460,7 @@ class BatchIndexer:
|
||||
return _PreparedEntity(
|
||||
path=file.path,
|
||||
entity_id=updated.id,
|
||||
permalink=updated.permalink,
|
||||
checksum=checksum,
|
||||
content_type=file.content_type,
|
||||
search_content=None,
|
||||
@@ -437,8 +493,14 @@ class BatchIndexer:
|
||||
async def resolve_relation(relation: Relation) -> int:
|
||||
async with semaphore:
|
||||
try:
|
||||
# strict=True for deferred resolution: only fill in to_id on an
|
||||
# exact permalink/title/file_path match. Fuzzy fallback would silently
|
||||
# resolve ambiguous links to whichever entity shares tokens with the
|
||||
# link text, mismatching this with the sync_service forward-reference
|
||||
# path and producing confidently-wrong graph edges. See
|
||||
# sync_service.resolve_forward_references for the same change.
|
||||
resolved_entity = await self.entity_service.link_resolver.resolve_link(
|
||||
relation.to_name
|
||||
relation.to_name, strict=True
|
||||
)
|
||||
if resolved_entity is None or resolved_entity.id == relation.from_id:
|
||||
return 0
|
||||
@@ -495,6 +557,99 @@ class BatchIndexer:
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _persist_markdown_file(
|
||||
self,
|
||||
prepared: _PreparedMarkdownFile,
|
||||
*,
|
||||
is_new: bool | None = None,
|
||||
resolve_relations: bool = True,
|
||||
reload_entity: bool = True,
|
||||
) -> _PersistedMarkdownFile:
|
||||
existing = await self.entity_repository.get_by_file_path(
|
||||
prepared.file.path,
|
||||
load_relations=False,
|
||||
)
|
||||
if is_new is None:
|
||||
is_new = existing is None
|
||||
entity = await self.entity_service.upsert_entity_from_markdown(
|
||||
Path(prepared.file.path),
|
||||
prepared.markdown,
|
||||
is_new=is_new,
|
||||
existing_entity=existing,
|
||||
resolve_relations=resolve_relations,
|
||||
reload_entity=reload_entity,
|
||||
)
|
||||
prepared = await self._reconcile_persisted_permalink(prepared, entity)
|
||||
metadata_updates = self._entity_metadata_updates(prepared.file, prepared.final_checksum)
|
||||
updated = await self.entity_repository.update_fields(
|
||||
entity.id,
|
||||
metadata_updates,
|
||||
)
|
||||
if not updated:
|
||||
raise ValueError(f"Failed to update markdown entity metadata for {prepared.file.path}")
|
||||
self._apply_entity_metadata_updates(entity, metadata_updates)
|
||||
return _PersistedMarkdownFile(prepared=prepared, entity=entity)
|
||||
|
||||
async def _reconcile_persisted_permalink(
|
||||
self,
|
||||
prepared: _PreparedMarkdownFile,
|
||||
entity: Entity,
|
||||
) -> _PreparedMarkdownFile:
|
||||
# Trigger: the source file started without frontmatter and sync is configured
|
||||
# to leave frontmatterless files alone.
|
||||
# Why: upsert may still assign a DB permalink even when disk content should stay untouched.
|
||||
# Outcome: skip reconciliation writes that would silently inject frontmatter.
|
||||
if (
|
||||
self.app_config.disable_permalinks
|
||||
or (
|
||||
not prepared.file_contains_frontmatter
|
||||
and not self.app_config.ensure_frontmatter_on_sync
|
||||
)
|
||||
or entity.permalink is None
|
||||
or entity.permalink == prepared.markdown.frontmatter.permalink
|
||||
):
|
||||
return prepared
|
||||
|
||||
logger.debug(
|
||||
"Updating permalink after upsert conflict resolution",
|
||||
path=prepared.file.path,
|
||||
old_permalink=prepared.markdown.frontmatter.permalink,
|
||||
new_permalink=entity.permalink,
|
||||
)
|
||||
prepared.markdown.frontmatter.metadata["permalink"] = entity.permalink
|
||||
write_result = await self.file_writer.write_frontmatter(
|
||||
IndexFrontmatterUpdate(
|
||||
path=prepared.file.path,
|
||||
metadata={"permalink": entity.permalink},
|
||||
)
|
||||
)
|
||||
return _PreparedMarkdownFile(
|
||||
file=prepared.file,
|
||||
content=write_result.content,
|
||||
final_checksum=write_result.checksum,
|
||||
markdown=prepared.markdown,
|
||||
file_contains_frontmatter=prepared.file_contains_frontmatter,
|
||||
)
|
||||
|
||||
def _build_prepared_entity(
|
||||
self,
|
||||
prepared: _PreparedMarkdownFile,
|
||||
entity: Entity,
|
||||
) -> _PreparedEntity:
|
||||
return _PreparedEntity(
|
||||
path=prepared.file.path,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
checksum=prepared.final_checksum,
|
||||
content_type=prepared.file.content_type,
|
||||
search_content=(
|
||||
prepared.markdown.content
|
||||
if prepared.markdown.content is not None
|
||||
else remove_frontmatter(prepared.content)
|
||||
),
|
||||
markdown_content=prepared.content,
|
||||
)
|
||||
|
||||
async def _resolve_checksum(self, file: IndexInputFile) -> str:
|
||||
if file.checksum is not None:
|
||||
return file.checksum
|
||||
@@ -523,6 +678,11 @@ class BatchIndexer:
|
||||
updates["content_type"] = file.content_type
|
||||
return updates
|
||||
|
||||
def _apply_entity_metadata_updates(self, entity: Entity, updates: dict[str, object]) -> None:
|
||||
"""Keep the returned entity aligned with metadata written without reload."""
|
||||
for key, value in updates.items():
|
||||
setattr(entity, key, value)
|
||||
|
||||
def _is_markdown(self, file: IndexInputFile) -> bool:
|
||||
if file.content_type is not None:
|
||||
return file.content_type == "text/markdown"
|
||||
|
||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Protocol, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.models import Entity
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -75,6 +78,19 @@ class IndexedEntity:
|
||||
markdown_content: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncedMarkdownFile:
|
||||
"""Canonical result for syncing one markdown file end-to-end."""
|
||||
|
||||
entity: Entity
|
||||
checksum: str
|
||||
markdown_content: str
|
||||
file_path: str
|
||||
content_type: str
|
||||
updated_at: datetime
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IndexingBatchResult:
|
||||
"""Outcome for one batch execution."""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -180,6 +200,9 @@ def observation_plugin(md: MarkdownIt) -> None:
|
||||
def observation_rule(state: Any) -> None:
|
||||
"""Process observations in token stream."""
|
||||
tokens = state.tokens
|
||||
# Track blockquote nesting so Obsidian callouts (`> [!info] Title`)
|
||||
# don't get parsed as observations with category `!info`.
|
||||
blockquote_depth = 0
|
||||
|
||||
for idx in range(len(tokens)):
|
||||
token = tokens[idx]
|
||||
@@ -187,6 +210,18 @@ def observation_plugin(md: MarkdownIt) -> None:
|
||||
# Initialize meta for all tokens
|
||||
token.meta = token.meta or {}
|
||||
|
||||
if token.type == "blockquote_open":
|
||||
blockquote_depth += 1
|
||||
continue
|
||||
if token.type == "blockquote_close":
|
||||
blockquote_depth -= 1
|
||||
continue
|
||||
|
||||
# Skip parsing inside blockquotes — that's Obsidian callout
|
||||
# territory, not Basic Memory observation syntax.
|
||||
if blockquote_depth > 0:
|
||||
continue
|
||||
|
||||
# Parse observations in list items
|
||||
if token.type == "inline" and is_observation(token):
|
||||
obs = parse_observation(token)
|
||||
@@ -202,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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Schema models for entity markdown files."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class Observation(BaseModel):
|
||||
@@ -38,23 +38,47 @@ class Relation(BaseModel):
|
||||
class EntityFrontmatter(BaseModel):
|
||||
"""Required frontmatter fields for an entity."""
|
||||
|
||||
metadata: dict = {}
|
||||
if TYPE_CHECKING:
|
||||
# Frontmatter may be built from raw YAML keys. The validator below
|
||||
# gathers those keys into the metadata mapping used at runtime.
|
||||
def __init__(self, **data: Any) -> None: ...
|
||||
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def collect_metadata(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if "metadata" not in data:
|
||||
return {"metadata": data}
|
||||
|
||||
metadata = data.get("metadata") or {}
|
||||
extras = {key: value for key, value in data.items() if key != "metadata"}
|
||||
if extras:
|
||||
return {"metadata": {**extras, **metadata}}
|
||||
return data
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
return self.metadata.get("tags") if self.metadata else None # pyright: ignore
|
||||
tags = self.metadata.get("tags")
|
||||
return [str(tag) for tag in tags] if isinstance(tags, list) else []
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.metadata.get("title") if self.metadata else None # pyright: ignore
|
||||
title = self.metadata.get("title")
|
||||
return title if isinstance(title, str) else ""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.metadata.get("type", "note") if self.metadata else "note" # pyright: ignore
|
||||
note_type = self.metadata.get("type", "note")
|
||||
return note_type if isinstance(note_type, str) else "note"
|
||||
|
||||
@property
|
||||
def permalink(self) -> str:
|
||||
return self.metadata.get("permalink") if self.metadata else None # pyright: ignore
|
||||
def permalink(self) -> Optional[str]:
|
||||
permalink = self.metadata.get("permalink")
|
||||
return permalink if isinstance(permalink, str) else None
|
||||
|
||||
|
||||
class EntityMarkdown(BaseModel):
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import os
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
from asyncio import Lock
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient, Timeout
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
import logfire
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
|
||||
_MISSING_STATE_VALUE = object()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PreparedLocalAsgiDatabase:
|
||||
active_count: int
|
||||
previous_engine: object
|
||||
previous_session_maker: object
|
||||
dependency_context: AbstractAsyncContextManager[LocalDatabaseState]
|
||||
|
||||
|
||||
_prepared_local_asgi_database_lock = RLock()
|
||||
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
|
||||
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
|
||||
|
||||
|
||||
def _force_local_mode() -> bool:
|
||||
"""Check if local mode is forced via environment variable."""
|
||||
@@ -35,16 +57,186 @@ def _build_timeout() -> Timeout:
|
||||
)
|
||||
|
||||
|
||||
def _asgi_client(timeout: Timeout) -> AsyncClient:
|
||||
"""Create a local ASGI client."""
|
||||
def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
|
||||
"""Create a local ASGI client for an already-prepared 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=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(),
|
||||
)
|
||||
|
||||
|
||||
def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
|
||||
"""Get the async lock that serializes first-time DB preparation for an app."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app)
|
||||
if prepare_lock is None:
|
||||
prepare_lock = Lock()
|
||||
_prepared_local_asgi_database_prepare_locks[app] = prepare_lock
|
||||
return prepare_lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
|
||||
"""Resolve database state for a local ASGI request."""
|
||||
from fastapi.dependencies.utils import get_dependant, solve_dependencies
|
||||
|
||||
from basic_memory.deps import get_engine_factory
|
||||
|
||||
async def resolve_database_state(
|
||||
database_state: Annotated[LocalDatabaseState, Depends(get_engine_factory)],
|
||||
) -> LocalDatabaseState:
|
||||
return database_state
|
||||
|
||||
scope: dict[str, Any] = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/",
|
||||
"raw_path": b"/",
|
||||
"root_path": "",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("testclient", 50000),
|
||||
"server": ("testserver", 80),
|
||||
"app": app,
|
||||
"path_params": {},
|
||||
}
|
||||
|
||||
async with AsyncExitStack() as request_stack, AsyncExitStack() as function_stack:
|
||||
scope["fastapi_inner_astack"] = request_stack
|
||||
scope["fastapi_function_astack"] = function_stack
|
||||
request = Request(scope)
|
||||
dependant = get_dependant(path="/", call=resolve_database_state)
|
||||
solved = await solve_dependencies(
|
||||
request=request,
|
||||
dependant=dependant,
|
||||
dependency_overrides_provider=app,
|
||||
async_exit_stack=request_stack,
|
||||
embed_body_fields=False,
|
||||
)
|
||||
if solved.errors:
|
||||
raise RuntimeError(f"Failed to resolve local ASGI database dependency: {solved.errors}")
|
||||
|
||||
yield await resolve_database_state(**solved.values)
|
||||
|
||||
|
||||
def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
|
||||
"""Retain an active local ASGI database preparation if one exists."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is None:
|
||||
return False
|
||||
|
||||
active.active_count += 1
|
||||
return True
|
||||
|
||||
|
||||
def _install_prepared_local_asgi_database(
|
||||
app: FastAPI,
|
||||
database_state: LocalDatabaseState,
|
||||
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
|
||||
) -> None:
|
||||
"""Install local ASGI database state after dependency resolution."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is not None:
|
||||
raise RuntimeError("Local ASGI database state installed while another state is active")
|
||||
|
||||
previous_engine = getattr(app.state, "engine", _MISSING_STATE_VALUE)
|
||||
previous_session_maker = getattr(app.state, "session_maker", _MISSING_STATE_VALUE)
|
||||
engine, session_maker = database_state
|
||||
|
||||
app.state.engine = engine
|
||||
app.state.session_maker = session_maker
|
||||
_prepared_local_asgi_databases[app] = _PreparedLocalAsgiDatabase(
|
||||
active_count=1,
|
||||
previous_engine=previous_engine,
|
||||
previous_session_maker=previous_session_maker,
|
||||
dependency_context=dependency_context,
|
||||
)
|
||||
|
||||
|
||||
def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None:
|
||||
"""Restore a FastAPI app.state attribute captured before local ASGI preparation."""
|
||||
if previous_value is _MISSING_STATE_VALUE:
|
||||
if hasattr(app.state, name):
|
||||
delattr(app.state, name)
|
||||
else:
|
||||
setattr(app.state, name, previous_value)
|
||||
|
||||
|
||||
def _release_prepared_local_asgi_database(
|
||||
app: FastAPI,
|
||||
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
|
||||
"""Release local ASGI database state after a client context exits."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is None:
|
||||
raise RuntimeError("Local ASGI database state released without a matching retain")
|
||||
|
||||
active.active_count -= 1
|
||||
if active.active_count > 0:
|
||||
return None
|
||||
|
||||
del _prepared_local_asgi_databases[app]
|
||||
_restore_local_asgi_state_attribute(app, "engine", active.previous_engine)
|
||||
_restore_local_asgi_state_attribute(
|
||||
app,
|
||||
"session_maker",
|
||||
active.previous_session_maker,
|
||||
)
|
||||
return active.dependency_context
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Initialize local ASGI database state before the first request."""
|
||||
prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app)
|
||||
async with prepare_lock:
|
||||
if not _retain_prepared_local_asgi_database(app):
|
||||
database_context = _resolve_local_asgi_database(app)
|
||||
database_state = await database_context.__aenter__()
|
||||
try:
|
||||
_install_prepared_local_asgi_database(app, database_state, database_context)
|
||||
except Exception:
|
||||
await database_context.__aexit__(None, None, None)
|
||||
raise
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
database_context = _release_prepared_local_asgi_database(app)
|
||||
if database_context is not None:
|
||||
await database_context.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _asgi_client(timeout: Timeout) -> AsyncIterator[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
|
||||
|
||||
# Trigger: local ASGITransport does not execute FastAPI lifespan startup.
|
||||
# Why: letting request dependencies initialize Postgres can run asyncpg DDL
|
||||
# under Starlette's request loop and trigger CPython's empty-ready-queue race.
|
||||
# Outcome: request handling sees the same app.state database objects as API
|
||||
# lifespan startup would have provided.
|
||||
async with _prepared_local_asgi_database(fastapi_app):
|
||||
async with _build_asgi_client(fastapi_app, timeout) as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _resolve_cloud_token(config) -> str:
|
||||
"""Resolve cloud token with API key preferred, OAuth fallback."""
|
||||
with telemetry.span(
|
||||
with logfire.span(
|
||||
"routing.resolve_cloud_credentials",
|
||||
has_api_key=bool(config.cloud_api_key),
|
||||
):
|
||||
@@ -94,9 +286,12 @@ async def _cloud_client(
|
||||
workspace: Optional[str] = None,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""Create a cloud proxy client with resolved credentials."""
|
||||
from basic_memory.workspace_context import workspace_permalink_headers
|
||||
|
||||
token = await _resolve_cloud_token(config)
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
headers.update(workspace_permalink_headers())
|
||||
if workspace:
|
||||
headers["X-Workspace-ID"] = workspace
|
||||
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
||||
@@ -248,7 +443,12 @@ def create_client() -> AsyncClient:
|
||||
|
||||
if _force_local_mode() or not _force_cloud_mode():
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
return _asgi_client(timeout)
|
||||
# Deprecated sync path: create_client() cannot await the local ASGI
|
||||
# pre-initialization used by get_client(), so callers that need proper
|
||||
# resource setup should use the async context manager instead.
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
return _build_asgi_client(fastapi_app, timeout)
|
||||
|
||||
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
|
||||
config = ConfigManager().config
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
|
||||
from basic_memory.schemas.response import (
|
||||
EntityResponse,
|
||||
@@ -15,6 +15,7 @@ from basic_memory.schemas.response import (
|
||||
DirectoryMoveResult,
|
||||
DirectoryDeleteResult,
|
||||
)
|
||||
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
|
||||
|
||||
|
||||
class KnowledgeClient:
|
||||
@@ -44,9 +45,7 @@ class KnowledgeClient:
|
||||
|
||||
# --- Entity CRUD Operations ---
|
||||
|
||||
async def create_entity(
|
||||
self, entity_data: dict[str, Any], *, fast: bool | None = None
|
||||
) -> EntityResponse:
|
||||
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
@@ -58,18 +57,15 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.create_entity",
|
||||
client_name="knowledge",
|
||||
operation="create_entity",
|
||||
fast=fast,
|
||||
):
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities",
|
||||
json=entity_data,
|
||||
params=params,
|
||||
client_name="knowledge",
|
||||
operation="create_entity",
|
||||
path_template="/v2/projects/{project_id}/knowledge/entities",
|
||||
@@ -80,8 +76,6 @@ class KnowledgeClient:
|
||||
self,
|
||||
entity_id: str,
|
||||
entity_data: dict[str, Any],
|
||||
*,
|
||||
fast: bool | None = None,
|
||||
) -> EntityResponse:
|
||||
"""Update an existing entity (full replacement).
|
||||
|
||||
@@ -95,18 +89,15 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.update_entity",
|
||||
client_name="knowledge",
|
||||
operation="update_entity",
|
||||
fast=fast,
|
||||
):
|
||||
response = await call_put(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=entity_data,
|
||||
params=params,
|
||||
client_name="knowledge",
|
||||
operation="update_entity",
|
||||
path_template="/v2/projects/{project_id}/knowledge/entities/{entity_id}",
|
||||
@@ -125,7 +116,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the entity is not found or request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.get_entity",
|
||||
client_name="knowledge",
|
||||
operation="get_entity",
|
||||
@@ -143,8 +134,6 @@ class KnowledgeClient:
|
||||
self,
|
||||
entity_id: str,
|
||||
patch_data: dict[str, Any],
|
||||
*,
|
||||
fast: bool | None = None,
|
||||
) -> EntityResponse:
|
||||
"""Partially update an entity.
|
||||
|
||||
@@ -158,18 +147,15 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.patch_entity",
|
||||
client_name="knowledge",
|
||||
operation="patch_entity",
|
||||
fast=fast,
|
||||
):
|
||||
response = await call_patch(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=patch_data,
|
||||
params=params,
|
||||
client_name="knowledge",
|
||||
operation="patch_entity",
|
||||
path_template="/v2/projects/{project_id}/knowledge/entities/{entity_id}",
|
||||
@@ -188,7 +174,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the entity is not found or request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.delete_entity",
|
||||
client_name="knowledge",
|
||||
operation="delete_entity",
|
||||
@@ -215,7 +201,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.move_entity",
|
||||
client_name="knowledge",
|
||||
operation="move_entity",
|
||||
@@ -245,7 +231,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.move_directory",
|
||||
client_name="knowledge",
|
||||
operation="move_directory",
|
||||
@@ -275,7 +261,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.delete_directory",
|
||||
client_name="knowledge",
|
||||
operation="delete_directory",
|
||||
@@ -290,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:
|
||||
@@ -305,7 +309,7 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the identifier cannot be resolved
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.knowledge.resolve_entity",
|
||||
client_name="knowledge",
|
||||
operation="resolve_entity",
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
@@ -72,7 +72,7 @@ class MemoryClient:
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.memory.build_context",
|
||||
client_name="memory",
|
||||
operation="build_context",
|
||||
@@ -123,7 +123,7 @@ class MemoryClient:
|
||||
# Join types as comma-separated string if provided
|
||||
params["type"] = ",".join(types) if isinstance(types, list) else types
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.memory.recent_activity",
|
||||
client_name="memory",
|
||||
operation="recent_activity",
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
|
||||
@@ -39,19 +37,11 @@ class ResourceClient:
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/resource"
|
||||
|
||||
async def read(
|
||||
self,
|
||||
entity_id: str,
|
||||
*,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
) -> Response:
|
||||
async def read(self, entity_id: str) -> Response:
|
||||
"""Read a resource by entity ID.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
page: Optional page number for paginated content
|
||||
page_size: Optional page size for paginated content
|
||||
|
||||
Returns:
|
||||
Raw HTTP Response (caller handles text/binary content)
|
||||
@@ -59,23 +49,14 @@ class ResourceClient:
|
||||
Raises:
|
||||
ToolError: If the resource is not found or request fails
|
||||
"""
|
||||
params: dict = {}
|
||||
if page is not None:
|
||||
params["page"] = page
|
||||
if page_size is not None:
|
||||
params["page_size"] = page_size
|
||||
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.resource.read",
|
||||
client_name="resource",
|
||||
operation="read",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
return await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/{entity_id}",
|
||||
params=params if params else None,
|
||||
client_name="resource",
|
||||
operation="read",
|
||||
path_template="/v2/projects/{project_id}/resource/{entity_id}",
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
|
||||
@@ -57,7 +57,7 @@ class SearchClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
with telemetry.scope(
|
||||
with logfire.span(
|
||||
"mcp.client.search.search",
|
||||
client_name="search",
|
||||
operation="search",
|
||||
|
||||
+1033
-144
File diff suppressed because it is too large
Load Diff
@@ -95,8 +95,8 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
sections = []
|
||||
|
||||
# Process each context
|
||||
for context in context.results: # pyright: ignore
|
||||
for primary in context.primary_results: # pyright: ignore
|
||||
for context_item in context.results:
|
||||
for primary in context_item.primary_results:
|
||||
if primary.permalink not in added_permalinks:
|
||||
primary_permalink = primary.permalink
|
||||
|
||||
@@ -121,8 +121,8 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
section += f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}\n"
|
||||
|
||||
# Add content snippet
|
||||
if hasattr(primary, "content") and primary.content: # pyright: ignore
|
||||
content = primary.content or "" # pyright: ignore # pragma: no cover
|
||||
if hasattr(primary, "content") and primary.content:
|
||||
content = primary.content or "" # pragma: no cover
|
||||
if content: # pragma: no cover
|
||||
section += f"\n**Excerpt**:\n{content}\n" # pragma: no cover
|
||||
|
||||
@@ -132,14 +132,14 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
""")
|
||||
sections.append(section)
|
||||
|
||||
if context.related_results: # pyright: ignore
|
||||
section += dedent( # pyright: ignore
|
||||
if context_item.related_results:
|
||||
section += dedent(
|
||||
"""
|
||||
## Related Context
|
||||
"""
|
||||
)
|
||||
|
||||
for related in context.related_results: # pyright: ignore
|
||||
for related in context_item.related_results:
|
||||
section_content = dedent(f"""
|
||||
- type: **{related.type}**
|
||||
- title: {related.title}
|
||||
|
||||
@@ -18,13 +18,15 @@ Basic Memory creates a semantic knowledge graph from markdown files. Focus on bu
|
||||
|
||||
**Resolution priority:**
|
||||
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
|
||||
2. Explicit parameter: `project="name"` in tool calls
|
||||
2. Explicit parameter: `project_id="<uuid>"` (preferred when known) or `project="name"` in tool calls
|
||||
3. Default project: `default_project` in config (fallback)
|
||||
|
||||
**`project` vs `project_id`:** Every project has a stable `external_id` (UUID) returned by `list_memory_projects()`. Pass it as `project_id=...` to address a project unambiguously — required when the same project name exists in multiple cloud workspaces. For local single-project setups, the `project` name is fine.
|
||||
|
||||
### Quick Setup Check
|
||||
|
||||
```python
|
||||
# Discover projects
|
||||
# Discover projects (each entry includes external_id you can pass as project_id)
|
||||
projects = await list_memory_projects()
|
||||
```
|
||||
|
||||
@@ -169,6 +171,14 @@ await write_note(
|
||||
**Multi-project users:**
|
||||
- Always specify project explicitly in tool calls
|
||||
|
||||
**Cloud multi-workspace users:** project names can collide across workspaces. After calling `list_memory_projects()`, prefer the project's `external_id` via `project_id=...` for any subsequent tool calls — it routes to the exact project regardless of name collisions. The `project` name parameter falls back to the default workspace on ambiguity, which may not be what you want.
|
||||
|
||||
```python
|
||||
# Cloud / multi-workspace: prefer project_id (UUID) once you've discovered it
|
||||
projects = await list_memory_projects()
|
||||
results = await search_notes(query="auth", project_id=projects[0]["external_id"])
|
||||
```
|
||||
|
||||
**Discovery:**
|
||||
```python
|
||||
# Start with discovery
|
||||
|
||||
@@ -15,7 +15,7 @@ from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.db import scoped_session
|
||||
from basic_memory.mcp.container import McpContainer, set_container
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory import telemetry
|
||||
import logfire
|
||||
|
||||
|
||||
async def _log_embedding_status(session_maker: async_sessionmaker[AsyncSession]) -> None:
|
||||
@@ -63,7 +63,7 @@ async def lifespan(app: FastMCP):
|
||||
set_container(container)
|
||||
|
||||
config = container.config
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.lifecycle.startup",
|
||||
entrypoint="mcp",
|
||||
mode=container.mode.name.lower(),
|
||||
@@ -124,7 +124,7 @@ async def lifespan(app: FastMCP):
|
||||
yield
|
||||
finally:
|
||||
# Shutdown - coordinator handles clean task cancellation
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.lifecycle.shutdown",
|
||||
entrypoint="mcp",
|
||||
mode=container.mode.name.lower(),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Build context tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from typing import Annotated, Optional, Literal
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -133,14 +134,34 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def build_context(
|
||||
url: MemoryUrl,
|
||||
url: Annotated[
|
||||
MemoryUrl,
|
||||
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
|
||||
],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
depth: str | int | None = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
timeframe: Annotated[
|
||||
Optional[TimeFrame],
|
||||
Field(
|
||||
default="7d",
|
||||
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
|
||||
),
|
||||
] = "7d",
|
||||
# `offset` is intentionally NOT aliased: it has different semantics
|
||||
# (item-indexed vs. 1-indexed page-number).
|
||||
page: Annotated[
|
||||
int,
|
||||
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
|
||||
] = 1,
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
|
||||
] = 10,
|
||||
max_related: Annotated[
|
||||
int,
|
||||
Field(default=10, validation_alias=AliasChoices("max_related", "max_results")),
|
||||
] = 10,
|
||||
output_format: Literal["json", "text"] = "json",
|
||||
context: Context | None = None,
|
||||
) -> dict | str:
|
||||
@@ -158,6 +179,9 @@ async def build_context(
|
||||
Args:
|
||||
project: Project name to build context from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (1-3 recommended for performance)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
@@ -185,9 +209,14 @@ async def build_context(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(url, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
url,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
@@ -202,12 +231,12 @@ async def build_context(
|
||||
|
||||
# URL is already validated and normalized by MemoryUrl type annotation
|
||||
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.tool.build_context",
|
||||
entrypoint="mcp",
|
||||
tool_name="build_context",
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
requested_project_id=project_id,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
@@ -216,48 +245,46 @@ async def build_context(
|
||||
output_format=output_format,
|
||||
is_memory_url=str(url).startswith("memory://"),
|
||||
):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
with telemetry.contextualize(
|
||||
project_name=active_project.name,
|
||||
workspace_id=workspace,
|
||||
tool_name="build_context",
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=build_context project={active_project.name} "
|
||||
f"url={url} depth={depth} timeframe={timeframe} output_format={output_format}"
|
||||
)
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=build_context project={active_project.name} "
|
||||
f"url={url} depth={depth} timeframe={timeframe} output_format={output_format}"
|
||||
)
|
||||
|
||||
# Resolve memory:// identifier with project-prefix awareness
|
||||
_, resolved_path, _ = await resolve_project_and_path(
|
||||
client,
|
||||
url,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
# Resolve memory:// identifier with project-prefix awareness
|
||||
_, resolved_path, _ = await resolve_project_and_path(
|
||||
client,
|
||||
url,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import MemoryClient
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import MemoryClient
|
||||
|
||||
# Use typed MemoryClient for API calls
|
||||
memory_client = MemoryClient(client, active_project.external_id)
|
||||
graph = await memory_client.build_context(
|
||||
resolved_path,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
# Use typed MemoryClient for API calls
|
||||
memory_client = MemoryClient(client, active_project.external_id)
|
||||
graph = await memory_client.build_context(
|
||||
resolved_path,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=build_context project={active_project.name} "
|
||||
f"uri={graph.metadata.uri or resolved_path} "
|
||||
f"primary_count={graph.metadata.primary_count or 0} "
|
||||
f"related_count={graph.metadata.related_count or 0} "
|
||||
f"output_format={output_format}"
|
||||
)
|
||||
logger.info(
|
||||
f"MCP tool response: tool=build_context project={active_project.name} "
|
||||
f"uri={graph.metadata.uri or resolved_path} "
|
||||
f"primary_count={graph.metadata.primary_count or 0} "
|
||||
f"related_count={graph.metadata.related_count or 0} "
|
||||
f"output_format={output_format}"
|
||||
)
|
||||
|
||||
if output_format == "text":
|
||||
return _format_context_markdown(graph, active_project.name)
|
||||
if output_format == "text":
|
||||
return _format_context_markdown(graph, active_project.name)
|
||||
|
||||
return graph.model_dump()
|
||||
return graph.model_dump()
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Annotated, Dict, List, Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic import AliasChoices, BeforeValidator, Field
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.utils import coerce_list
|
||||
@@ -24,9 +24,12 @@ async def canvas(
|
||||
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
|
||||
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
|
||||
title: str,
|
||||
directory: str,
|
||||
directory: Annotated[
|
||||
str,
|
||||
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
|
||||
],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Create an Obsidian canvas file with the provided nodes and edges.
|
||||
@@ -43,6 +46,9 @@ async def canvas(
|
||||
Args:
|
||||
project: Project name to create canvas in. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
nodes: List of node objects following JSON Canvas 1.0 spec
|
||||
edges: List of edge objects following JSON Canvas 1.0 spec
|
||||
title: The title of the canvas (will be saved as title.canvas)
|
||||
@@ -97,7 +103,10 @@ async def canvas(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
file_path = f"{directory}/{file_title}"
|
||||
|
||||
@@ -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,9 +191,7 @@ async def fetch(
|
||||
# which works in both local mode (ConfigManager) and cloud mode (database).
|
||||
content = str(
|
||||
await read_note(
|
||||
identifier=id,
|
||||
page=1,
|
||||
page_size=10,
|
||||
identifier=_identifier_for_read_note(id),
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
from typing import Annotated, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from basic_memory.utils import generate_permalink, normalize_project_reference
|
||||
from basic_memory.workspace_context import current_workspace_permalink_context
|
||||
|
||||
|
||||
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
||||
@@ -147,15 +155,44 @@ delete_note("{project}", "correct-identifier-from-search")
|
||||
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
|
||||
|
||||
|
||||
def _directory_path_for_delete(
|
||||
target_identifier: str,
|
||||
active_project: ProjectItem,
|
||||
*,
|
||||
include_project_prefix: bool,
|
||||
) -> str:
|
||||
"""Return the project-relative directory path expected by the delete API."""
|
||||
directory = normalize_project_reference(target_identifier).strip("/")
|
||||
project_permalink = active_project.permalink
|
||||
|
||||
route_prefixes: list[str] = []
|
||||
workspace_context = current_workspace_permalink_context()
|
||||
if workspace_context and workspace_context.should_prefix_permalinks:
|
||||
route_prefixes.append(
|
||||
f"{generate_permalink(workspace_context.workspace_slug)}/{project_permalink}"
|
||||
)
|
||||
if include_project_prefix:
|
||||
route_prefixes.append(project_permalink)
|
||||
|
||||
for route_prefix in route_prefixes:
|
||||
if directory.startswith(f"{route_prefix}/"):
|
||||
return directory.removeprefix(f"{route_prefix}/")
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Delete a note or directory by title, permalink, or path",
|
||||
annotations={"destructiveHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def delete_note(
|
||||
identifier: str,
|
||||
is_directory: bool = False,
|
||||
is_directory: Annotated[
|
||||
bool,
|
||||
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
|
||||
] = False,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> bool | str | dict:
|
||||
@@ -179,6 +216,9 @@ async def delete_note(
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to delete from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
output_format: "text" preserves existing behavior (bool/string). "json"
|
||||
returns machine-readable deletion metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
@@ -224,16 +264,23 @@ async def delete_note(
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
# Trigger: identifier starts with memory:// and no explicit project was provided
|
||||
# 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 identifier.strip().startswith("memory://"):
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
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,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.debug(
|
||||
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
|
||||
)
|
||||
@@ -243,11 +290,30 @@ async def delete_note(
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
_, target_identifier, is_memory_url = await resolve_project_and_path(
|
||||
client,
|
||||
identifier,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
|
||||
# Handle directory deletes
|
||||
if is_directory:
|
||||
try:
|
||||
result = await knowledge_client.delete_directory(identifier)
|
||||
# Trigger: directory input was routed from a memory:// URL.
|
||||
# Why: resolve_project_and_path returns canonical permalinks, while
|
||||
# delete_directory filters by project-relative file_path prefixes.
|
||||
# Outcome: strip only the route prefix before calling the delete API.
|
||||
directory_identifier = (
|
||||
_directory_path_for_delete(
|
||||
target_identifier,
|
||||
active_project,
|
||||
include_project_prefix=ConfigManager().config.permalinks_include_project,
|
||||
)
|
||||
if is_memory_url
|
||||
else target_identifier
|
||||
)
|
||||
result = await knowledge_client.delete_directory(directory_identifier)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": result.failed_deletes == 0,
|
||||
@@ -329,7 +395,7 @@ delete_note("path/to/file.md")
|
||||
note_file_path = None
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
|
||||
if output_format == "json":
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
note_title = entity.title
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
"""Edit note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from typing import Annotated, Optional, Literal
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
_workspace_identifier_discovery_available,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
add_project_metadata,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
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]:
|
||||
@@ -45,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,
|
||||
@@ -170,11 +229,34 @@ Error editing note '{identifier}': {error_message}
|
||||
async def edit_note(
|
||||
identifier: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
# Accept common replacement-content aliases. Models trained on diff/patch
|
||||
# APIs reach for new_content/replacement/replace_with on first try.
|
||||
content: Annotated[
|
||||
str,
|
||||
Field(
|
||||
validation_alias=AliasChoices("content", "new_content", "replacement", "replace_with")
|
||||
),
|
||||
],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
# Section/heading naming varies across tools; accept the descriptive forms.
|
||||
section: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("section", "section_heading", "heading"),
|
||||
),
|
||||
] = None,
|
||||
# find_text is the highest-frequency miss per the issue: models reach for
|
||||
# find/old_text/old_content/search before find_text every time.
|
||||
find_text: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("find_text", "find", "old_text", "old_content", "search"),
|
||||
),
|
||||
] = None,
|
||||
expected_replacements: Optional[int] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
@@ -200,7 +282,13 @@ 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().
|
||||
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
||||
find_text: For find_replace operation - the text to find and replace
|
||||
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
||||
@@ -260,254 +348,292 @@ 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 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 identifier.strip().startswith("memory://"):
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
# Resolve or reject routable identifier prefixes before selecting a client.
|
||||
# Trigger: no explicit project/project_id was provided.
|
||||
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
|
||||
# are ambiguous for a mutating tool.
|
||||
# Outcome: memory:// can route; plain workspace/project/path matches stop with
|
||||
# guidance instead of silently editing another project.
|
||||
if project is None and project_id is None:
|
||||
config = ConfigManager().config
|
||||
if identifier.strip().startswith("memory://"):
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
identifier,
|
||||
config,
|
||||
context=context,
|
||||
)
|
||||
elif _workspace_identifier_discovery_available(
|
||||
identifier,
|
||||
config,
|
||||
) and is_workspace_qualified_plain_identifier(identifier):
|
||||
detected = await detect_project_from_workspace_identifier_prefix(
|
||||
identifier,
|
||||
config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"fileCreated": False,
|
||||
"error": "AMBIGUOUS_IDENTIFIER",
|
||||
"project": detected,
|
||||
}
|
||||
return _format_ambiguous_workspace_identifier_response(
|
||||
identifier=identifier,
|
||||
detected_project=detected,
|
||||
)
|
||||
else:
|
||||
detected = None
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.tool.edit_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="edit_note",
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
requested_project_id=project_id,
|
||||
edit_operation=operation,
|
||||
output_format=output_format,
|
||||
has_section=bool(section),
|
||||
has_find_text=bool(find_text),
|
||||
expected_replacements=effective_replacements,
|
||||
):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
with telemetry.contextualize(
|
||||
project_name=active_project.name,
|
||||
workspace_id=workspace,
|
||||
tool_name="edit_note",
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=edit_note project={active_project.name} "
|
||||
f"identifier={identifier} operation={operation} output_format={output_format}"
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=edit_note project={active_project.name} "
|
||||
f"identifier={identifier} operation={operation} output_format={output_format}"
|
||||
)
|
||||
|
||||
# Validate operation
|
||||
valid_operations = [
|
||||
"append",
|
||||
"prepend",
|
||||
"find_replace",
|
||||
"replace_section",
|
||||
"insert_before_section",
|
||||
"insert_after_section",
|
||||
]
|
||||
if operation not in valid_operations:
|
||||
raise ValueError(
|
||||
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
|
||||
)
|
||||
|
||||
# Validate operation
|
||||
valid_operations = [
|
||||
"append",
|
||||
"prepend",
|
||||
"find_replace",
|
||||
"replace_section",
|
||||
"insert_before_section",
|
||||
"insert_after_section",
|
||||
]
|
||||
if operation not in valid_operations:
|
||||
raise ValueError(
|
||||
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
|
||||
)
|
||||
# Validate required parameters for specific operations
|
||||
if operation == "find_replace" and not find_text:
|
||||
raise ValueError("find_text parameter is required for find_replace operation")
|
||||
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
|
||||
if operation in section_ops and not section:
|
||||
raise ValueError("section parameter is required for section-based operations")
|
||||
|
||||
# Validate required parameters for specific operations
|
||||
if operation == "find_replace" and not find_text:
|
||||
raise ValueError("find_text parameter is required for find_replace operation")
|
||||
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
|
||||
if operation in section_ops and not section:
|
||||
raise ValueError("section parameter is required for section-based operations")
|
||||
# Use the PATCH endpoint to edit the entity
|
||||
try:
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use the PATCH endpoint to edit the entity
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
_, entity_identifier, _ = await resolve_project_and_path(
|
||||
client,
|
||||
identifier,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
|
||||
file_created = False
|
||||
entity_id = ""
|
||||
result: EntityResponse | None = None
|
||||
|
||||
# Try to resolve the entity; for append/prepend, create it if not found
|
||||
try:
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
entity_identifier,
|
||||
strict=True,
|
||||
)
|
||||
except Exception as resolve_error:
|
||||
# Trigger: entity does not exist yet
|
||||
# Why: append/prepend can meaningfully create a new note from the content,
|
||||
# while find_replace/replace_section require existing content to modify
|
||||
# Outcome: note is created via the same path as write_note
|
||||
error_msg = str(resolve_error).lower()
|
||||
is_not_found = "entity not found" in error_msg or "not found" in error_msg
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
if is_not_found and operation in ("append", "prepend"):
|
||||
title, directory = _parse_identifier_to_title_and_directory(identifier)
|
||||
|
||||
file_created = False
|
||||
entity_id = ""
|
||||
result: EntityResponse | None = None
|
||||
|
||||
# Try to resolve the entity; for append/prepend, create it if not found
|
||||
try:
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
except Exception as resolve_error:
|
||||
# Trigger: entity does not exist yet
|
||||
# Why: append/prepend can meaningfully create a new note from the content,
|
||||
# while find_replace/replace_section require existing content to modify
|
||||
# Outcome: note is created via the same path as write_note
|
||||
error_msg = str(resolve_error).lower()
|
||||
is_not_found = "entity not found" in error_msg or "not found" in error_msg
|
||||
|
||||
if is_not_found and operation in ("append", "prepend"):
|
||||
title, directory = _parse_identifier_to_title_and_directory(identifier)
|
||||
|
||||
# Validate directory path (same security check as write_note)
|
||||
project_path = active_project.home
|
||||
if directory and not validate_project_path(directory, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
directory=directory,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"fileCreated": False,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
entity = Entity(
|
||||
title=title,
|
||||
# Validate directory path (same security check as write_note)
|
||||
project_path = active_project.home
|
||||
if directory and not validate_project_path(directory, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
directory=directory,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"fileCreated": False,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
logger.info(
|
||||
"Creating note via edit_note auto-create",
|
||||
title=title,
|
||||
directory=directory,
|
||||
operation=operation,
|
||||
)
|
||||
result = await knowledge_client.create_entity(
|
||||
entity.model_dump(), fast=False
|
||||
)
|
||||
file_created = True
|
||||
else:
|
||||
# find_replace/replace_section require existing content — re-raise
|
||||
raise resolve_error
|
||||
|
||||
# --- Standard edit path (entity already existed) ---
|
||||
if not file_created:
|
||||
# Prepare the edit request data
|
||||
edit_data = {
|
||||
"operation": operation,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if section:
|
||||
edit_data["section"] = section
|
||||
if find_text:
|
||||
edit_data["find_text"] = find_text
|
||||
if effective_replacements != 1: # Only send if different from default
|
||||
edit_data["expected_replacements"] = str(effective_replacements)
|
||||
|
||||
# Call the PATCH endpoint
|
||||
result = await knowledge_client.patch_entity(
|
||||
entity_id, edit_data, fast=False
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
)
|
||||
|
||||
# --- Format response ---
|
||||
# result is always set: either by create_entity (auto-create) or patch_entity (edit)
|
||||
assert result is not None
|
||||
if file_created:
|
||||
summary = [
|
||||
f"# Created note ({operation})",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
"fileCreated: true",
|
||||
]
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Created note with {lines_added} lines")
|
||||
logger.info(
|
||||
"Creating note via edit_note auto-create",
|
||||
title=title,
|
||||
directory=directory,
|
||||
operation=operation,
|
||||
)
|
||||
result = await knowledge_client.create_entity(entity.model_dump())
|
||||
file_created = True
|
||||
else:
|
||||
summary = [
|
||||
f"# Edited note ({operation})",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
# find_replace/replace_section require existing content — re-raise
|
||||
raise resolve_error
|
||||
|
||||
# Add operation-specific details
|
||||
if operation == "append":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Added {lines_added} lines to end of note")
|
||||
elif operation == "prepend":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(
|
||||
f"operation: Added {lines_added} lines to beginning of note"
|
||||
)
|
||||
elif operation == "find_replace":
|
||||
# For find_replace, we can't easily count replacements from here
|
||||
# since we don't have the original content, but the server handled it
|
||||
summary.append("operation: Find and replace operation completed")
|
||||
elif operation == "replace_section":
|
||||
summary.append(f"operation: Replaced content under section '{section}'")
|
||||
elif operation == "insert_before_section":
|
||||
summary.append(
|
||||
f"operation: Inserted content before section '{section}'"
|
||||
)
|
||||
elif operation == "insert_after_section":
|
||||
summary.append(f"operation: Inserted content after section '{section}'")
|
||||
# --- Standard edit path (entity already existed) ---
|
||||
if not file_created:
|
||||
# Prepare the edit request data
|
||||
edit_data = {
|
||||
"operation": operation,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
# Count observations by category (reuse logic from write_note)
|
||||
categories = {}
|
||||
if result.observations:
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
# Add optional parameters
|
||||
if section:
|
||||
edit_data["section"] = section
|
||||
if find_text:
|
||||
edit_data["find_text"] = find_text
|
||||
if effective_replacements != 1: # Only send if different from default
|
||||
edit_data["expected_replacements"] = str(effective_replacements)
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
# Call the PATCH endpoint
|
||||
result = await knowledge_client.patch_entity(entity_id, edit_data)
|
||||
|
||||
# Count resolved/unresolved relations
|
||||
unresolved = 0
|
||||
resolved = 0
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
# --- Format response ---
|
||||
# result is always set: either by create_entity (auto-create) or patch_entity (edit)
|
||||
assert result is not None
|
||||
if file_created:
|
||||
summary = [
|
||||
f"# Created note ({operation})",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
"fileCreated: true",
|
||||
]
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Created note with {lines_added} lines")
|
||||
else:
|
||||
summary = [
|
||||
f"# Edited note ({operation})",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
# Add operation-specific details
|
||||
if operation == "append":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Added {lines_added} lines to end of note")
|
||||
elif operation == "prepend":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Added {lines_added} lines to beginning of note")
|
||||
elif operation == "find_replace":
|
||||
# For find_replace, we can't easily count replacements from here
|
||||
# since we don't have the original content, but the server handled it
|
||||
summary.append("operation: Find and replace operation completed")
|
||||
elif operation == "replace_section":
|
||||
summary.append(f"operation: Replaced content under section '{section}'")
|
||||
elif operation == "insert_before_section":
|
||||
summary.append(f"operation: Inserted content before section '{section}'")
|
||||
elif operation == "insert_after_section":
|
||||
summary.append(f"operation: Inserted content after section '{section}'")
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=edit_note project={active_project.name} "
|
||||
f"operation={operation} permalink={result.permalink} "
|
||||
f"observations_count={len(result.observations)} "
|
||||
f"relations_count={len(result.relations)} "
|
||||
f"file_created={str(file_created).lower()}"
|
||||
)
|
||||
# Count observations by category (reuse logic from write_note)
|
||||
categories = {}
|
||||
if result.observations:
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"operation": operation,
|
||||
"fileCreated": file_created,
|
||||
}
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
# Count resolved/unresolved relations
|
||||
unresolved = 0
|
||||
resolved = 0
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing note: {e}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"fileCreated": False,
|
||||
"error": str(e),
|
||||
}
|
||||
return _format_error_response(
|
||||
str(e),
|
||||
operation,
|
||||
identifier,
|
||||
find_text,
|
||||
effective_replacements,
|
||||
active_project.name,
|
||||
)
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
|
||||
logger.info(
|
||||
f"MCP tool response: tool=edit_note project={active_project.name} "
|
||||
f"operation={operation} permalink={result.permalink} "
|
||||
f"observations_count={len(result.observations)} "
|
||||
f"relations_count={len(result.relations)} "
|
||||
f"file_created={str(file_created).lower()}"
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"operation": operation,
|
||||
"fileCreated": file_created,
|
||||
}
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing note: {e}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"operation": operation,
|
||||
"fileCreated": False,
|
||||
"error": str(e),
|
||||
}
|
||||
return _format_error_response(
|
||||
str(e),
|
||||
operation,
|
||||
identifier,
|
||||
find_text,
|
||||
effective_replacements,
|
||||
active_project.name,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""List directory tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -14,11 +15,24 @@ from basic_memory.mcp.server import mcp
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def list_directory(
|
||||
dir_name: str = "/",
|
||||
# `dir_name` is unusual; models reach for directory/folder/path/dir.
|
||||
dir_name: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="/",
|
||||
validation_alias=AliasChoices("dir_name", "directory", "folder", "path", "dir"),
|
||||
),
|
||||
] = "/",
|
||||
depth: int = 1,
|
||||
file_name_glob: Optional[str] = None,
|
||||
file_name_glob: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("file_name_glob", "glob", "pattern", "filter"),
|
||||
),
|
||||
] = None,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""List directory contents from the knowledge base with optional filtering.
|
||||
@@ -36,6 +50,9 @@ async def list_directory(
|
||||
Examples: "*.md", "*meeting*", "project_*"
|
||||
project: Project name to list directory from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -63,7 +80,10 @@ async def list_directory(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.debug(
|
||||
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
||||
)
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
from typing import Annotated, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
@@ -348,11 +349,29 @@ delete_note("{identifier}")
|
||||
)
|
||||
async def move_note(
|
||||
identifier: str,
|
||||
destination_path: str = "",
|
||||
destination_folder: Optional[str] = None,
|
||||
is_directory: bool = False,
|
||||
# Move/rename APIs across the ecosystem use `to`/`destination`/`new_path`.
|
||||
destination_path: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices(
|
||||
"destination_path", "dest_path", "new_path", "to", "destination"
|
||||
),
|
||||
),
|
||||
] = "",
|
||||
destination_folder: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("destination_folder", "dest_folder", "to_folder"),
|
||||
),
|
||||
] = None,
|
||||
is_directory: Annotated[
|
||||
bool,
|
||||
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
|
||||
] = False,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
@@ -377,6 +396,9 @@ async def move_note(
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to move within. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
output_format: "text" returns existing markdown guidance/success text. "json"
|
||||
returns machine-readable move metadata.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
@@ -476,7 +498,10 @@ async def move_note(
|
||||
"error": "DESTINATION_FOLDER_NOT_FOR_DIRECTORIES",
|
||||
}
|
||||
return f"# Move Failed - Invalid Parameters\n\n{error_msg}"
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
destination_target = destination_folder or destination_path
|
||||
logger.info(
|
||||
f"MCP tool call tool=move_note project={active_project.name} "
|
||||
|
||||
@@ -10,8 +10,23 @@ from typing import Literal
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import ConfigManager, has_cloud_credentials
|
||||
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
|
||||
from basic_memory.config import (
|
||||
BasicMemoryConfig,
|
||||
ConfigManager,
|
||||
ProjectEntry,
|
||||
has_cloud_credentials,
|
||||
)
|
||||
from basic_memory.mcp.async_client import (
|
||||
_explicit_routing,
|
||||
_force_local_mode,
|
||||
get_client,
|
||||
is_factory_mode,
|
||||
)
|
||||
from basic_memory.mcp.project_context import (
|
||||
WorkspaceProjectEntry,
|
||||
ensure_workspace_project_index,
|
||||
resolve_workspace_parameter,
|
||||
)
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink
|
||||
@@ -20,30 +35,6 @@ from basic_memory.utils import generate_permalink
|
||||
# --- Helpers for dual-fetch + merge ---
|
||||
|
||||
|
||||
async def _fetch_cloud_projects(
|
||||
workspace: str | None = None,
|
||||
context: Context | None = None,
|
||||
) -> ProjectList | None:
|
||||
"""Fetch projects from the cloud API, returning None on failure.
|
||||
|
||||
Logs warnings on failure so the caller can fall back to local-only results.
|
||||
"""
|
||||
try:
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
async with get_cloud_proxy_client(workspace=workspace) as cloud_client:
|
||||
cloud_project_client = ProjectClient(cloud_client)
|
||||
cloud_list = await cloud_project_client.list_projects()
|
||||
if context: # pragma: no cover
|
||||
await context.info(f"Discovered {len(cloud_list.projects)} cloud projects")
|
||||
return cloud_list
|
||||
except Exception as exc:
|
||||
logger.warning(f"Cloud project discovery failed: {exc}")
|
||||
if context: # pragma: no cover
|
||||
await context.info("Cloud project discovery failed, showing local projects only")
|
||||
return None
|
||||
|
||||
|
||||
def _merge_projects(
|
||||
local_list: ProjectList | None,
|
||||
cloud_list: ProjectList | None,
|
||||
@@ -51,6 +42,8 @@ def _merge_projects(
|
||||
cloud_workspace_name: str | None = None,
|
||||
cloud_workspace_type: str | None = None,
|
||||
cloud_workspace_tenant_id: str | None = None,
|
||||
cloud_workspace_slug: str | None = None,
|
||||
cloud_workspace_is_default: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Merge local and cloud project lists by permalink.
|
||||
|
||||
@@ -113,9 +106,13 @@ def _merge_projects(
|
||||
ws_type = cloud_workspace_type if cloud_proj else None
|
||||
ws_tenant_id = cloud_workspace_tenant_id if cloud_proj else None
|
||||
|
||||
proj = cloud_proj or local_proj
|
||||
external_id = proj.external_id if proj else None
|
||||
|
||||
merged.append(
|
||||
{
|
||||
"name": name,
|
||||
"external_id": external_id,
|
||||
"path": path,
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
@@ -126,21 +123,197 @@ def _merge_projects(
|
||||
"workspace_name": ws_name,
|
||||
"workspace_type": ws_type,
|
||||
"workspace_tenant_id": ws_tenant_id,
|
||||
"workspace_slug": cloud_workspace_slug if cloud_proj else None,
|
||||
"workspace_is_default": cloud_workspace_is_default if cloud_proj else False,
|
||||
"qualified_name": (
|
||||
f"{cloud_workspace_slug}/{permalink}"
|
||||
if cloud_proj and cloud_workspace_slug
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _workspace_entry_priority(entry: WorkspaceProjectEntry) -> tuple[bool, int, str, str]:
|
||||
"""Prefer default/personal workspaces when duplicate project permalinks exist."""
|
||||
workspace_type_rank = 0 if entry.workspace.workspace_type == "personal" else 1
|
||||
return (
|
||||
# False sorts before True, so the cloud/default workspace comes first.
|
||||
not entry.workspace.is_default,
|
||||
workspace_type_rank,
|
||||
entry.workspace.name.casefold(),
|
||||
entry.workspace.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def _select_attached_cloud_entry(
|
||||
cloud_entries: tuple[WorkspaceProjectEntry, ...],
|
||||
*,
|
||||
config_entry: ProjectEntry | None,
|
||||
config: BasicMemoryConfig | None,
|
||||
) -> WorkspaceProjectEntry | None:
|
||||
"""Choose the single cloud row that should inherit local project state."""
|
||||
if not cloud_entries:
|
||||
return None
|
||||
|
||||
preferred_workspace_ids: list[str] = []
|
||||
if config_entry and config_entry.workspace_id:
|
||||
preferred_workspace_ids.append(config_entry.workspace_id)
|
||||
if (
|
||||
config
|
||||
and config.default_workspace
|
||||
and config.default_workspace not in preferred_workspace_ids
|
||||
):
|
||||
preferred_workspace_ids.append(config.default_workspace)
|
||||
|
||||
# The configured default workspace can differ from the cloud-side default.
|
||||
# Use the cloud default only after explicit local config preferences.
|
||||
default_workspace_entry = next(
|
||||
(entry for entry in cloud_entries if entry.workspace.is_default),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
default_workspace_entry is not None
|
||||
and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids
|
||||
):
|
||||
preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id)
|
||||
|
||||
for workspace_id in preferred_workspace_ids:
|
||||
for entry in cloud_entries:
|
||||
if entry.workspace.tenant_id == workspace_id:
|
||||
return entry
|
||||
|
||||
if len(cloud_entries) == 1:
|
||||
return cloud_entries[0]
|
||||
|
||||
return sorted(cloud_entries, key=_workspace_entry_priority)[0]
|
||||
|
||||
|
||||
def _merge_workspace_projects(
|
||||
local_list: ProjectList | None,
|
||||
cloud_entries: tuple[WorkspaceProjectEntry, ...],
|
||||
*,
|
||||
config: BasicMemoryConfig | None = None,
|
||||
) -> list[dict]:
|
||||
"""Merge local projects with cloud projects from every accessible workspace."""
|
||||
local_by_permalink: dict[str, ProjectItem] = {}
|
||||
if local_list:
|
||||
for project in local_list.projects:
|
||||
local_by_permalink[project.permalink] = project
|
||||
|
||||
config_by_permalink: dict[str, ProjectEntry] = {}
|
||||
if config:
|
||||
config_by_permalink = {
|
||||
generate_permalink(project_name): entry
|
||||
for project_name, entry in config.projects.items()
|
||||
}
|
||||
|
||||
cloud_entries_by_permalink: dict[str, list[WorkspaceProjectEntry]] = {}
|
||||
for entry in cloud_entries:
|
||||
cloud_entries_by_permalink.setdefault(entry.project.permalink, []).append(entry)
|
||||
|
||||
attached_entry_by_permalink: dict[str, WorkspaceProjectEntry | None] = {}
|
||||
for permalink in local_by_permalink:
|
||||
attached_entry_by_permalink[permalink] = _select_attached_cloud_entry(
|
||||
tuple(cloud_entries_by_permalink.get(permalink, ())),
|
||||
config_entry=config_by_permalink.get(permalink),
|
||||
config=config,
|
||||
)
|
||||
|
||||
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
|
||||
merged: list[dict] = []
|
||||
|
||||
for entry in sorted(
|
||||
cloud_entries,
|
||||
key=lambda item: (*_workspace_entry_priority(item), item.project.permalink),
|
||||
):
|
||||
permalink = entry.project.permalink
|
||||
local_proj = (
|
||||
local_by_permalink.get(permalink)
|
||||
# WorkspaceProjectEntry is a frozen dataclass containing Pydantic
|
||||
# models, so value equality is the intended comparison here.
|
||||
if attached_entry_by_permalink.get(permalink) == entry
|
||||
else None
|
||||
)
|
||||
cloud_proj = entry.project
|
||||
source = "local+cloud" if local_proj else "cloud"
|
||||
local_path = local_proj.path if local_proj else None
|
||||
cloud_path = cloud_proj.path
|
||||
|
||||
merged.append(
|
||||
{
|
||||
"name": cloud_proj.name,
|
||||
"external_id": cloud_proj.external_id,
|
||||
"path": local_path or cloud_path,
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
"source": source,
|
||||
"is_default": bool((local_proj and local_proj.is_default) or cloud_proj.is_default),
|
||||
"is_private": cloud_proj.is_private,
|
||||
"display_name": cloud_proj.display_name,
|
||||
"workspace_name": entry.workspace.name,
|
||||
"workspace_type": entry.workspace.workspace_type,
|
||||
"workspace_tenant_id": entry.workspace.tenant_id,
|
||||
"workspace_slug": entry.workspace.slug,
|
||||
"workspace_is_default": entry.workspace.is_default,
|
||||
"qualified_name": entry.qualified_name,
|
||||
}
|
||||
)
|
||||
|
||||
if local_list:
|
||||
for project in sorted(local_list.projects, key=lambda item: item.permalink):
|
||||
if project.permalink in cloud_permalinks:
|
||||
continue
|
||||
merged.append(
|
||||
{
|
||||
"name": project.name,
|
||||
"external_id": project.external_id,
|
||||
"path": project.path,
|
||||
"local_path": project.path,
|
||||
"cloud_path": None,
|
||||
"source": "local",
|
||||
"is_default": project.is_default,
|
||||
"is_private": project.is_private,
|
||||
"display_name": project.display_name,
|
||||
"workspace_name": None,
|
||||
"workspace_type": None,
|
||||
"workspace_tenant_id": None,
|
||||
"workspace_slug": None,
|
||||
"workspace_is_default": False,
|
||||
"qualified_name": None,
|
||||
}
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _format_project_list_text(merged: list[dict]) -> str:
|
||||
"""Format merged project list as human-readable text."""
|
||||
result = "Available projects:\n"
|
||||
|
||||
current_workspace: tuple[str | None, str | None] | None = None
|
||||
for project in merged:
|
||||
workspace_slug = project.get("workspace_slug")
|
||||
workspace_name = project.get("workspace_name")
|
||||
if workspace_slug:
|
||||
workspace_key = (workspace_slug, workspace_name)
|
||||
if workspace_key != current_workspace:
|
||||
default_label = " default" if project.get("workspace_is_default") else ""
|
||||
result += f"\nWorkspace: {workspace_name} ({workspace_slug}{default_label})\n"
|
||||
current_workspace = workspace_key
|
||||
elif current_workspace is not None:
|
||||
result += "\nLocal projects:\n"
|
||||
current_workspace = None
|
||||
|
||||
display_name = project["display_name"]
|
||||
name = project["name"]
|
||||
label = f"{display_name} ({name})" if display_name else name
|
||||
source = project["source"]
|
||||
result += f"• {label} ({source})\n"
|
||||
external_id = project.get("external_id", "")
|
||||
id_suffix = f" [{external_id}]" if external_id else ""
|
||||
result += f"- {label} ({source}){id_suffix}\n"
|
||||
|
||||
result += "\n" + "─" * 40 + "\n"
|
||||
result += "Next: Ask which project to use for this session.\n"
|
||||
@@ -172,7 +345,6 @@ def _format_project_list_json(
|
||||
)
|
||||
async def list_memory_projects(
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
workspace: str | None = None,
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
"""List all available projects with their status.
|
||||
@@ -180,11 +352,14 @@ async def list_memory_projects(
|
||||
Shows projects from both local and cloud sources when cloud credentials
|
||||
are available, merging by permalink to give a unified view.
|
||||
|
||||
Each project entry includes an `external_id` (UUID). Pass that value as the
|
||||
`project_id` parameter on other tools to address a specific project
|
||||
unambiguously across cloud workspaces — useful when the same project name
|
||||
exists in more than one workspace.
|
||||
|
||||
Args:
|
||||
output_format: "text" returns the existing human-readable project list.
|
||||
"json" returns structured project metadata.
|
||||
workspace: Cloud workspace name or tenant_id. Falls back to
|
||||
config.default_workspace when not specified.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
"""
|
||||
if context: # pragma: no cover
|
||||
@@ -196,18 +371,21 @@ async def list_memory_projects(
|
||||
|
||||
# --- Factory mode (cloud app) ---
|
||||
# Trigger: set_client_factory() was called (e.g., basic-memory-cloud)
|
||||
# Why: there is no local ASGI server; the factory IS the only source
|
||||
# Outcome: single fetch, no merge needed
|
||||
# Why: there is no local ASGI server; the factory IS the cloud source
|
||||
# Outcome: fetch every accessible workspace so callers can discover cross-workspace IDs
|
||||
if is_factory_mode():
|
||||
async with get_client() as client:
|
||||
project_client = ProjectClient(client)
|
||||
project_list = await project_client.list_projects()
|
||||
|
||||
merged = _merge_projects(project_list, None)
|
||||
workspace_index = await ensure_workspace_project_index(context=context)
|
||||
merged = _merge_workspace_projects(None, workspace_index.entries)
|
||||
default_project = next(
|
||||
(
|
||||
entry.project.name
|
||||
for entry in workspace_index.entries
|
||||
if entry.workspace.is_default and entry.project.is_default
|
||||
),
|
||||
None,
|
||||
)
|
||||
if output_format == "json":
|
||||
return _format_project_list_json(
|
||||
merged, project_list.default_project, constrained_project
|
||||
)
|
||||
return _format_project_list_json(merged, default_project, constrained_project)
|
||||
if constrained_project:
|
||||
return _format_constrained_text(constrained_project)
|
||||
return _format_project_list_text(merged)
|
||||
@@ -220,39 +398,40 @@ async def list_memory_projects(
|
||||
|
||||
# Fetch cloud projects when credentials are available
|
||||
cloud_list: ProjectList | None = None
|
||||
cloud_entries: tuple[WorkspaceProjectEntry, ...] = ()
|
||||
cloud_ws_name: str | None = None
|
||||
cloud_ws_type: str | None = None
|
||||
cloud_ws_tenant_id: str | None = None
|
||||
cloud_ws_slug: str | None = None
|
||||
cloud_ws_is_default = False
|
||||
config = ConfigManager().config
|
||||
if has_cloud_credentials(config):
|
||||
# Use explicit workspace, fall back to config default
|
||||
effective_workspace = workspace or config.default_workspace
|
||||
cloud_list = await _fetch_cloud_projects(effective_workspace, context)
|
||||
|
||||
# Resolve workspace metadata so each cloud project carries its workspace info
|
||||
if cloud_list:
|
||||
cloud_ws_tenant_id = effective_workspace
|
||||
try:
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
|
||||
workspaces = await get_available_workspaces(context)
|
||||
matched = next(
|
||||
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
|
||||
None,
|
||||
try:
|
||||
workspace_index = await ensure_workspace_project_index(context=context)
|
||||
cloud_entries = workspace_index.entries
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Cloud workspace project index discovery failed while listing projects; "
|
||||
f"showing local-only project list: {exc}"
|
||||
)
|
||||
if context: # pragma: no cover
|
||||
await context.info(
|
||||
"Cloud workspace project discovery failed while listing projects; "
|
||||
"showing local projects only"
|
||||
)
|
||||
if matched:
|
||||
cloud_ws_name = matched.name
|
||||
cloud_ws_type = matched.workspace_type
|
||||
except Exception:
|
||||
pass # workspace lookup is best-effort
|
||||
|
||||
merged = _merge_projects(
|
||||
local_list,
|
||||
cloud_list,
|
||||
cloud_workspace_name=cloud_ws_name,
|
||||
cloud_workspace_type=cloud_ws_type,
|
||||
cloud_workspace_tenant_id=cloud_ws_tenant_id,
|
||||
)
|
||||
if cloud_entries:
|
||||
merged = _merge_workspace_projects(local_list, cloud_entries, config=config)
|
||||
else:
|
||||
merged = _merge_projects(
|
||||
local_list,
|
||||
cloud_list,
|
||||
cloud_workspace_name=cloud_ws_name,
|
||||
cloud_workspace_type=cloud_ws_type,
|
||||
cloud_workspace_tenant_id=cloud_ws_tenant_id,
|
||||
cloud_workspace_slug=cloud_ws_slug,
|
||||
cloud_workspace_is_default=cloud_ws_is_default,
|
||||
)
|
||||
default_project = local_list.default_project
|
||||
|
||||
if output_format == "json":
|
||||
@@ -272,6 +451,32 @@ def _format_constrained_text(constrained_project: str) -> str:
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_workspace_routing(
|
||||
workspace: str | None,
|
||||
context: Context | None,
|
||||
) -> str | None:
|
||||
"""Resolve an optional workspace selector to the routing tenant id."""
|
||||
if workspace is None:
|
||||
return None
|
||||
|
||||
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
|
||||
config = ConfigManager().config
|
||||
should_resolve_workspace = is_factory_mode() or (
|
||||
explicit_cloud_routing and has_cloud_credentials(config)
|
||||
)
|
||||
if not should_resolve_workspace:
|
||||
return workspace
|
||||
|
||||
# Trigger: cloud routing can use workspace discovery and the caller supplied
|
||||
# a friendly selector such as a slug, name, or tenant id.
|
||||
# Why: MCP callers should not need to paste UUIDs, but the transport still
|
||||
# uses X-Workspace-ID with the tenant id as its routing authority.
|
||||
# Outcome: resolve once before the project-management request and pass only
|
||||
# the tenant id downstream.
|
||||
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
|
||||
return resolved_workspace.tenant_id
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
"create_memory_project",
|
||||
annotations={"destructiveHint": False, "openWorldHint": False},
|
||||
@@ -280,6 +485,7 @@ async def create_memory_project(
|
||||
project_name: str,
|
||||
project_path: str,
|
||||
set_default: bool = False,
|
||||
workspace: str | None = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
@@ -292,6 +498,11 @@ async def create_memory_project(
|
||||
project_name: Name for the new project (must be unique)
|
||||
project_path: File system path where the project will be stored
|
||||
set_default: Whether to set this project as the default (optional, defaults to False)
|
||||
workspace: Optional cloud workspace selector to create the project in. Slug is
|
||||
preferred for AI callers, but tenant_id and unique name are also accepted.
|
||||
When omitted, the connection's default workspace is used. Discover values
|
||||
via `list_workspaces`. In local mode the selector is passed through
|
||||
without slug resolution.
|
||||
output_format: "text" returns the existing human-readable result text.
|
||||
"json" returns structured project creation metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
@@ -302,26 +513,36 @@ async def create_memory_project(
|
||||
Example:
|
||||
create_memory_project("my-research", "~/Documents/research")
|
||||
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
||||
create_memory_project("team-notes", "/team/notes", workspace="team-paul")
|
||||
"""
|
||||
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:
|
||||
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}"`'
|
||||
# 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:
|
||||
if context: # pragma: no cover
|
||||
await context.info(f"Creating project: {project_name} at {project_path}")
|
||||
|
||||
@@ -347,6 +568,7 @@ async def create_memory_project(
|
||||
if output_format == "json":
|
||||
return {
|
||||
"name": existing_match.name,
|
||||
"external_id": existing_match.external_id,
|
||||
"path": existing_match.path,
|
||||
"is_default": is_default,
|
||||
"created": False,
|
||||
@@ -356,17 +578,22 @@ async def create_memory_project(
|
||||
f"✓ Project already exists: {existing_match.name}\n\n"
|
||||
f"Project Details:\n"
|
||||
f"• Name: {existing_match.name}\n"
|
||||
f"• External ID: {existing_match.external_id}\n"
|
||||
f"• Path: {existing_match.path}\n"
|
||||
f"{'• Set as default project\n' if is_default else ''}"
|
||||
"\nProject is already available for use in tool calls.\n"
|
||||
)
|
||||
|
||||
status_response = await project_client.create_project(project_request.model_dump())
|
||||
from basic_memory.mcp.project_context import invalidate_workspace_project_index
|
||||
|
||||
await invalidate_workspace_project_index(context)
|
||||
|
||||
if output_format == "json":
|
||||
new_project = status_response.new_project
|
||||
return {
|
||||
"name": new_project.name if new_project else project_name,
|
||||
"external_id": new_project.external_id if new_project else None,
|
||||
"path": new_project.path if new_project else project_path,
|
||||
"is_default": bool(
|
||||
(new_project.is_default if new_project else False) or set_default
|
||||
@@ -380,6 +607,7 @@ async def create_memory_project(
|
||||
if status_response.new_project:
|
||||
result += "Project Details:\n"
|
||||
result += f"• Name: {status_response.new_project.name}\n"
|
||||
result += f"• External ID: {status_response.new_project.external_id}\n"
|
||||
result += f"• Path: {status_response.new_project.path}\n"
|
||||
|
||||
if set_default:
|
||||
@@ -394,7 +622,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
|
||||
@@ -403,23 +635,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}")
|
||||
|
||||
@@ -451,6 +693,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
|
||||
# Delete project using project external_id
|
||||
status_response = await project_client.delete_project(target_project.external_id)
|
||||
from basic_memory.mcp.project_context import invalidate_workspace_project_index
|
||||
|
||||
await invalidate_workspace_project_index(context)
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@ Files are read directly without any knowledge graph processing.
|
||||
import base64
|
||||
import io
|
||||
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from PIL import Image as PILImage
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, Field
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -158,9 +159,12 @@ def optimize_image(img, content_length, max_output_bytes=350000):
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def read_content(
|
||||
path: str,
|
||||
path: Annotated[
|
||||
str,
|
||||
Field(validation_alias=AliasChoices("path", "file_path", "filepath", "file")),
|
||||
],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> dict:
|
||||
"""Read a file's raw content by path or permalink.
|
||||
@@ -181,6 +185,9 @@ async def read_content(
|
||||
- A permalink (docs/example)
|
||||
project: Project name to read from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -210,17 +217,27 @@ async def read_content(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If path attempts path traversal
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(path, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
path,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
logger.info(f"MCP tool call tool=read_content project={project} path={path}")
|
||||
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
# Resolve path with project-prefix awareness for memory:// URLs
|
||||
_, url, _ = await resolve_project_and_path(client, path, project, context)
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
# Resolve path with project-prefix awareness for memory:// URLs.
|
||||
# Use active_project.name so resolution stays consistent when project_id
|
||||
# was used or `project` was wrong/ambiguous (matches the cached resolution).
|
||||
_, url, _ = await resolve_project_and_path(client, path, active_project.name, context)
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""Read note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional, Literal, cast
|
||||
|
||||
import logfire
|
||||
import yaml
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_identifier_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -33,10 +33,10 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
|
||||
If parsing fails or frontmatter is not a mapping, returns body unchanged and None.
|
||||
"""
|
||||
original_content = content
|
||||
if not content.startswith("---\n"):
|
||||
lines = content.splitlines(keepends=True)
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return original_content, None
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
closing_index = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
@@ -70,9 +70,7 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
|
||||
async def read_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
include_frontmatter: bool = False,
|
||||
context: Context | None = None,
|
||||
@@ -96,10 +94,11 @@ async def read_note(
|
||||
project: Project name to read from. Optional - server will resolve using the
|
||||
hierarchy above. If unknown, use list_memory_projects() to discover
|
||||
available projects.
|
||||
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().
|
||||
identifier: The title or permalink of the note to read
|
||||
Can be a full memory:// URL, a permalink, a title, or search text
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
output_format: "text" returns markdown content or guidance text.
|
||||
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
|
||||
include_frontmatter: When output_format="json", whether content should include the
|
||||
@@ -120,9 +119,6 @@ async def read_note(
|
||||
# Read with memory URL
|
||||
read_note("my-research", "memory://specs/search-spec")
|
||||
|
||||
# Read with pagination
|
||||
read_note("work-project", "Project Updates", page=2, page_size=5)
|
||||
|
||||
# Read recent meeting notes
|
||||
read_note("team-docs", "Weekly Standup")
|
||||
|
||||
@@ -134,230 +130,240 @@ 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
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
# 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_identifier_prefix(
|
||||
identifier,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.tool.read_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="read_note",
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
requested_project_id=project_id,
|
||||
output_format=output_format,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
include_frontmatter=include_frontmatter,
|
||||
):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
with telemetry.contextualize(
|
||||
project_name=active_project.name,
|
||||
workspace_id=workspace,
|
||||
tool_name="read_note",
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
# Resolve identifier with project-prefix awareness for memory:// URLs.
|
||||
# Pass active_project.name (the canonical resolved name) rather than the
|
||||
# original `project` arg so the inner get_active_project cache hits even
|
||||
# when project_id was used or `project` was wrong/ambiguous.
|
||||
_, entity_path, _ = await resolve_project_and_path(
|
||||
client, identifier, active_project.name, context
|
||||
)
|
||||
|
||||
# Validate identifier to prevent path traversal attacks
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
# has a scheme prefix that confuses path validation)
|
||||
raw_path = (
|
||||
memory_url_path(identifier) if identifier.startswith("memory://") else identifier
|
||||
)
|
||||
processed_path = entity_path
|
||||
project_path = active_project.home
|
||||
|
||||
if not validate_project_path(raw_path, project_path) or not validate_project_path(
|
||||
processed_path, project_path
|
||||
):
|
||||
# Resolve identifier with project-prefix awareness for memory:// URLs
|
||||
_, entity_path, _ = await resolve_project_and_path(
|
||||
client, identifier, project, context
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
identifier=identifier,
|
||||
processed_path=processed_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
|
||||
# Validate identifier to prevent path traversal attacks
|
||||
# For memory:// URLs, validate the extracted path (not the raw URL which
|
||||
# has a scheme prefix that confuses path validation)
|
||||
raw_path = (
|
||||
memory_url_path(identifier)
|
||||
if identifier.startswith("memory://")
|
||||
else identifier
|
||||
)
|
||||
processed_path = entity_path
|
||||
project_path = active_project.home
|
||||
|
||||
if not validate_project_path(raw_path, project_path) or not validate_project_path(
|
||||
processed_path, project_path
|
||||
):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
identifier=identifier,
|
||||
processed_path=processed_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"content": None,
|
||||
"frontmatter": None,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Get the file via REST API - first try direct identifier resolution
|
||||
logger.info(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
|
||||
# Use typed clients for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
async def _read_json_payload(entity_id: str) -> dict:
|
||||
with telemetry.scope(
|
||||
"mcp.read_note.shape_response",
|
||||
domain="mcp",
|
||||
action="read_note",
|
||||
phase="shape_response",
|
||||
):
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
response = await resource_client.read(
|
||||
entity_id, page=page, page_size=page_size
|
||||
)
|
||||
content_text = response.text
|
||||
body_content, parsed_frontmatter = _parse_opening_frontmatter(content_text)
|
||||
return {
|
||||
"title": entity.title,
|
||||
"permalink": entity.permalink,
|
||||
"file_path": entity.file_path,
|
||||
"content": content_text if include_frontmatter else body_content,
|
||||
"frontmatter": parsed_frontmatter,
|
||||
}
|
||||
|
||||
def _empty_json_payload() -> dict:
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"content": None,
|
||||
"frontmatter": None,
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Get the file via REST API - first try direct identifier resolution
|
||||
logger.info(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
|
||||
# Use typed clients for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
async def _read_json_payload(entity_id: str) -> dict:
|
||||
with logfire.span(
|
||||
"mcp.read_note.shape_response",
|
||||
domain="mcp",
|
||||
action="read_note",
|
||||
phase="shape_response",
|
||||
):
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
response = await resource_client.read(entity_id)
|
||||
content_text = response.text
|
||||
body_content, parsed_frontmatter = _parse_opening_frontmatter(content_text)
|
||||
return {
|
||||
"title": entity.title,
|
||||
"permalink": entity.permalink,
|
||||
"file_path": entity.file_path,
|
||||
"content": content_text if include_frontmatter else body_content,
|
||||
"frontmatter": parsed_frontmatter,
|
||||
}
|
||||
|
||||
def _search_results(payload: object) -> list[dict]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
results = payload.get("results")
|
||||
return results if isinstance(results, list) else []
|
||||
def _empty_json_payload() -> dict:
|
||||
return {
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"content": None,
|
||||
"frontmatter": None,
|
||||
}
|
||||
|
||||
async def _search_candidates(identifier_text: str, *, title_only: bool) -> dict:
|
||||
# Trigger: direct entity resolution failed for the caller's identifier.
|
||||
# Why: search_notes applies the same memory:// normalization and tool-level
|
||||
# query handling as the rest of MCP routing, which raw client calls skip.
|
||||
# Outcome: unresolved memory URLs still fall back through normalized search.
|
||||
search_type = "title" if title_only else "text"
|
||||
response = await search_notes(
|
||||
project=active_project.name,
|
||||
workspace=workspace,
|
||||
query=identifier_text,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
return response if isinstance(response, dict) else {}
|
||||
def _search_results(payload: object) -> list[dict[str, object]]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
payload_dict = cast(dict[str, object], payload)
|
||||
results = payload_dict.get("results")
|
||||
if not isinstance(results, list):
|
||||
return []
|
||||
return [
|
||||
cast(dict[str, object], result)
|
||||
for result in results
|
||||
if isinstance(result, dict)
|
||||
]
|
||||
|
||||
def _result_title(item: dict) -> str:
|
||||
return str(item.get("title") or "")
|
||||
async def _search_candidates(
|
||||
identifier_text: str, *, title_only: bool
|
||||
) -> dict[str, object]:
|
||||
# Trigger: direct entity resolution failed for the caller's identifier.
|
||||
# Why: search_notes applies the same memory:// normalization and tool-level
|
||||
# query handling as the rest of MCP routing, which raw client calls skip.
|
||||
# Outcome: unresolved memory URLs still fall back through normalized search.
|
||||
# Pass project_id (external_id UUID) so the workspace selection from the
|
||||
# outer get_project_client() is preserved across the inner re-resolution.
|
||||
# Without this, project names that collide across workspaces could re-resolve
|
||||
# to a different tenant via the default-workspace fallback (CLI/context=None).
|
||||
search_type = "title" if title_only else "text"
|
||||
response = await search_notes(
|
||||
project=active_project.name,
|
||||
project_id=active_project.external_id,
|
||||
query=identifier_text,
|
||||
search_type=search_type,
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
return cast(dict[str, object], response) if isinstance(response, dict) else {}
|
||||
|
||||
def _result_permalink(item: dict) -> Optional[str]:
|
||||
value = item.get("permalink")
|
||||
return str(value) if value else None
|
||||
def _result_title(item: dict[str, object]) -> str:
|
||||
return str(item.get("title") or "")
|
||||
|
||||
def _result_file_path(item: dict) -> Optional[str]:
|
||||
value = item.get("file_path")
|
||||
return str(value) if value else None
|
||||
def _result_permalink(item: dict[str, object]) -> Optional[str]:
|
||||
value = item.get("permalink")
|
||||
return str(value) if value else None
|
||||
|
||||
try:
|
||||
# Try to resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
|
||||
def _result_file_path(item: dict[str, object]) -> Optional[str]:
|
||||
value = item.get("file_path")
|
||||
return str(value) if value else None
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
try:
|
||||
# Try to resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
|
||||
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"Returning read_note result from resource: {path}", path=entity_path
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
# Continue to fallback methods
|
||||
# Fetch content using entity ID
|
||||
response = await resource_client.read(entity_id)
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await _search_candidates(identifier, title_only=True)
|
||||
|
||||
title_candidates = _search_results(title_results)
|
||||
if title_candidates:
|
||||
# Trigger: direct resolution failed and title search returned candidates.
|
||||
# Why: avoid returning unrelated notes when search yields only fuzzy matches.
|
||||
# Outcome: fetch content only when a true exact title match exists.
|
||||
result = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in title_candidates
|
||||
if _is_exact_title_match(identifier, _result_title(candidate))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"No exact title match found for: {identifier}")
|
||||
elif _result_permalink(result):
|
||||
try:
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
_result_permalink(result) or "", strict=True
|
||||
)
|
||||
|
||||
# Fetch content using the entity ID
|
||||
response = await resource_client.read(
|
||||
entity_id, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
f"Found note by exact title search: {_result_permalink(result)}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
|
||||
)
|
||||
else:
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
f"No results in title search for: {identifier} in project {active_project.name}"
|
||||
"Returning read_note result from resource: {path}",
|
||||
path=entity_path,
|
||||
)
|
||||
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await _search_candidates(identifier, title_only=False)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
text_candidates = _search_results(text_results)
|
||||
if not text_candidates:
|
||||
if output_format == "json":
|
||||
return _empty_json_payload()
|
||||
return format_not_found_message(active_project.name, identifier)
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
# Continue to fallback methods
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await _search_candidates(identifier, title_only=True)
|
||||
|
||||
title_candidates = _search_results(title_results)
|
||||
if title_candidates:
|
||||
# Trigger: direct resolution failed and title search returned candidates.
|
||||
# Why: avoid returning unrelated notes when search yields only fuzzy matches.
|
||||
# Outcome: fetch content only when a true exact title match exists.
|
||||
result = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in title_candidates
|
||||
if _is_exact_title_match(identifier, _result_title(candidate))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"No exact title match found for: {identifier}")
|
||||
elif _result_permalink(result):
|
||||
try:
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
_result_permalink(result) or "", strict=True
|
||||
)
|
||||
|
||||
# Fetch content using the entity ID
|
||||
response = await resource_client.read(entity_id)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
f"Found note by exact title search: {_result_permalink(result)}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return await _read_json_payload(entity_id)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"No results in title search for: {identifier} in project {active_project.name}"
|
||||
)
|
||||
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await _search_candidates(identifier, title_only=False)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
text_candidates = _search_results(text_results)
|
||||
if not text_candidates:
|
||||
if output_format == "json":
|
||||
payload = _empty_json_payload()
|
||||
payload["related_results"] = [
|
||||
{
|
||||
"title": _result_title(result),
|
||||
"permalink": _result_permalink(result),
|
||||
"file_path": _result_file_path(result),
|
||||
}
|
||||
for result in text_candidates[:5]
|
||||
]
|
||||
return payload
|
||||
return format_related_results(active_project.name, identifier, text_candidates[:5])
|
||||
return _empty_json_payload()
|
||||
return format_not_found_message(active_project.name, identifier)
|
||||
if output_format == "json":
|
||||
payload = _empty_json_payload()
|
||||
payload["related_results"] = [
|
||||
{
|
||||
"title": _result_title(result),
|
||||
"permalink": _result_permalink(result),
|
||||
"file_path": _result_file_path(result),
|
||||
}
|
||||
for result in text_candidates[:5]
|
||||
]
|
||||
return payload
|
||||
return format_related_results(active_project.name, identifier, text_candidates[:5])
|
||||
|
||||
|
||||
def format_not_found_message(project: str | None, identifier: str) -> str:
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from datetime import timezone
|
||||
from pathlib import PurePosixPath
|
||||
from typing import List, Union, Optional, Literal
|
||||
from typing import Annotated, List, Union, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import (
|
||||
@@ -38,13 +39,30 @@ from basic_memory.schemas.search import SearchItemType
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def recent_activity(
|
||||
type: Union[str, List[str]] = "",
|
||||
type: Annotated[
|
||||
Union[str, List[str]],
|
||||
Field(default="", validation_alias=AliasChoices("type", "types", "kind")),
|
||||
] = "",
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
timeframe: Annotated[
|
||||
TimeFrame,
|
||||
Field(
|
||||
default="7d",
|
||||
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
|
||||
),
|
||||
] = "7d",
|
||||
# `offset` is intentionally NOT aliased: it has different semantics
|
||||
# (item-indexed vs. 1-indexed page-number).
|
||||
page: Annotated[
|
||||
int,
|
||||
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
|
||||
] = 1,
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
|
||||
] = 10,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | list[dict]:
|
||||
@@ -86,6 +104,9 @@ async def recent_activity(
|
||||
project: Project name to query. Optional - server will resolve using the
|
||||
hierarchy above. If unknown, use list_memory_projects() to discover
|
||||
available projects.
|
||||
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().
|
||||
output_format: "text" returns human-readable summary text. "json" returns
|
||||
a flat list of recent items.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
@@ -161,9 +182,14 @@ async def recent_activity(
|
||||
if "type" not in params:
|
||||
params["type"] = [SearchItemType.ENTITY.value]
|
||||
|
||||
# Resolve project parameter using the three-tier hierarchy
|
||||
# allow_discovery=True enables Discovery Mode, so a project is not required
|
||||
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
|
||||
# Resolve project parameter using the three-tier hierarchy.
|
||||
# allow_discovery=True enables Discovery Mode, so a project is not required.
|
||||
# project_id (UUID) takes precedence over project name — without this fallback,
|
||||
# callers passing only project_id would fall into Discovery Mode.
|
||||
effective_identifier = project_id if project_id else project
|
||||
resolved_project = await resolve_project_parameter(
|
||||
effective_identifier, allow_discovery=True, context=context
|
||||
)
|
||||
|
||||
if resolved_project is None:
|
||||
# Discovery Mode: Get activity across all projects
|
||||
@@ -278,7 +304,7 @@ async def recent_activity(
|
||||
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
|
||||
)
|
||||
|
||||
async with get_project_client(resolved_project, workspace, context) as (
|
||||
async with get_project_client(resolved_project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
@@ -464,10 +490,12 @@ def _format_project_output(
|
||||
elif result.primary_result.type == "observation":
|
||||
observations.append(result.primary_result)
|
||||
|
||||
# Show entities (notes/documents)
|
||||
# Show entities (notes/documents). Render every row the API returned —
|
||||
# `page_size` is the single knob for how much comes back, so heading count
|
||||
# and body row count always agree (regression: #784 silent truncation).
|
||||
if entities:
|
||||
lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
|
||||
for entity in entities[:5]: # Show top 5
|
||||
for entity in entities:
|
||||
title = entity.title or "Untitled"
|
||||
# Get folder from file_path
|
||||
folder = ""
|
||||
@@ -500,7 +528,7 @@ def _format_project_output(
|
||||
# Show relations (connections)
|
||||
if relations:
|
||||
lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
|
||||
for rel in relations[:5]: # Show top 5
|
||||
for rel in relations:
|
||||
rel_type = rel.relation_type
|
||||
from_entity = rel.from_entity or "Unknown"
|
||||
to_entity = rel.to_entity
|
||||
|
||||
@@ -211,7 +211,7 @@ async def schema_validate(
|
||||
note_type: Optional[str] = None,
|
||||
identifier: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> ValidationReport | str | dict:
|
||||
@@ -236,6 +236,9 @@ async def schema_validate(
|
||||
identifier: Specific note to validate (permalink, title, or path).
|
||||
If provided, validates only this note.
|
||||
project: Project name. Optional -- server will resolve.
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -251,7 +254,10 @@ async def schema_validate(
|
||||
# Validate in a specific project
|
||||
schema_validate(note_type="person", project="my-research")
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_validate project={active_project.name} "
|
||||
f"note_type={note_type} identifier={identifier}"
|
||||
@@ -318,7 +324,7 @@ async def schema_infer(
|
||||
note_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
@@ -342,6 +348,9 @@ async def schema_infer(
|
||||
threshold: Minimum frequency (0-1) for a field to be suggested as optional.
|
||||
Default 0.25 (25%). Fields above 95% become required.
|
||||
project: Project name. Optional -- server will resolve.
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -357,7 +366,10 @@ async def schema_infer(
|
||||
# Infer in a specific project
|
||||
schema_infer("person", project="my-research")
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_infer project={active_project.name} "
|
||||
f"note_type={note_type} threshold={threshold}"
|
||||
@@ -432,7 +444,7 @@ async def schema_infer(
|
||||
async def schema_diff(
|
||||
note_type: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
@@ -453,6 +465,9 @@ async def schema_diff(
|
||||
Args:
|
||||
note_type: The note type to check for drift (e.g., "person").
|
||||
project: Project name. Optional -- server will resolve.
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -466,7 +481,10 @@ async def schema_diff(
|
||||
# Check drift in a specific project
|
||||
schema_diff("person", project="my-research")
|
||||
"""
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=schema_diff project={active_project.name} note_type={note_type}"
|
||||
)
|
||||
|
||||
@@ -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 BeforeValidator
|
||||
from pydantic import AliasChoices, BeforeValidator, Field
|
||||
|
||||
from basic_memory import telemetry
|
||||
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_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
|
||||
@@ -301,27 +568,58 @@ def _format_search_markdown(result: SearchResponse, project: str, query: str | N
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def search_notes(
|
||||
query: Optional[str] = None,
|
||||
# Accept common search-query aliases models reach for from training data.
|
||||
# `q` is the universal HTTP convention; `search`/`text` are common in NL APIs.
|
||||
query: Annotated[
|
||||
Optional[str],
|
||||
Field(default=None, validation_alias=AliasChoices("query", "q", "search", "text")),
|
||||
] = None,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
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.
|
||||
page: Annotated[
|
||||
int,
|
||||
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
|
||||
] = 1,
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
|
||||
] = 10,
|
||||
search_type: str | None = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
# Plural-vs-singular trips models constantly. Accept the singular too.
|
||||
note_types: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
Field(default=None, validation_alias=AliasChoices("note_types", "note_type", "types")),
|
||||
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
|
||||
"Case-insensitive.",
|
||||
] = None,
|
||||
entity_types: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
Field(default=None, validation_alias=AliasChoices("entity_types", "entity_type")),
|
||||
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
|
||||
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
|
||||
"'Chapter' here — use note_types instead.",
|
||||
] = None,
|
||||
after_date: Optional[str] = None,
|
||||
# Time-filter naming varies wildly across APIs.
|
||||
after_date: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("after_date", "since", "after", "from_date"),
|
||||
),
|
||||
] = None,
|
||||
metadata_filters: Annotated[
|
||||
Dict[str, Any] | None,
|
||||
BeforeValidator(coerce_dict),
|
||||
@@ -331,7 +629,13 @@ async def search_notes(
|
||||
BeforeValidator(coerce_list),
|
||||
] = None,
|
||||
status: Optional[str] = None,
|
||||
min_similarity: Optional[float] = None,
|
||||
min_similarity: Annotated[
|
||||
Optional[float],
|
||||
Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("min_similarity", "threshold", "similarity_threshold"),
|
||||
),
|
||||
] = None,
|
||||
context: Context | None = None,
|
||||
) -> dict | str:
|
||||
"""Search across all content in the knowledge base with comprehensive syntax support.
|
||||
@@ -343,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
|
||||
|
||||
@@ -415,6 +721,11 @@ async def search_notes(
|
||||
Omit or pass None for filter-only searches using metadata_filters, tags, or status.
|
||||
project: Project name to search in. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
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:
|
||||
@@ -518,18 +829,46 @@ 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
|
||||
if project is None and query is not None:
|
||||
detected = detect_project_from_url_prefix(query, ConfigManager().config)
|
||||
# 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_identifier_prefix(
|
||||
query,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
with telemetry.operation(
|
||||
# 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,
|
||||
workspace_id=workspace,
|
||||
requested_project_id=project_id,
|
||||
search_all_projects=search_all_projects,
|
||||
search_type=search_type or "default",
|
||||
output_format=output_format,
|
||||
page=page,
|
||||
@@ -543,140 +882,140 @@ async def search_notes(
|
||||
has_tags_filter=bool(tags),
|
||||
has_status_filter=bool(status),
|
||||
):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
with telemetry.contextualize(
|
||||
project_name=active_project.name,
|
||||
workspace_id=workspace,
|
||||
tool_name="search_notes",
|
||||
):
|
||||
# Handle memory:// URLs by resolving to permalink search
|
||||
is_memory_url = False
|
||||
if query is not None:
|
||||
_, resolved_query, is_memory_url = await resolve_project_and_path(
|
||||
client, query, project, context
|
||||
)
|
||||
if is_memory_url:
|
||||
query = resolved_query
|
||||
effective_search_type = search_type or _default_search_type()
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
# Handle memory:// URLs by resolving to permalink search.
|
||||
# Use active_project.name so resolution hits the cached active project
|
||||
# when project_id was used or `project` was wrong/ambiguous.
|
||||
is_memory_url = False
|
||||
if query is not None:
|
||||
_, resolved_query, is_memory_url = await resolve_project_and_path(
|
||||
client, query, active_project.name, context
|
||||
)
|
||||
if is_memory_url:
|
||||
effective_search_type = "permalink"
|
||||
query = resolved_query
|
||||
effective_search_type = search_type or _default_search_type()
|
||||
if is_memory_url:
|
||||
effective_search_type = "permalink"
|
||||
|
||||
try:
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
try:
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Only map search_type to query fields when there is an actual query string.
|
||||
# When query is None/empty, skip the search mode block — filters-only path.
|
||||
effective_query = (query or "").strip()
|
||||
if effective_query:
|
||||
valid_search_types = {
|
||||
"text",
|
||||
"title",
|
||||
"permalink",
|
||||
"vector",
|
||||
"semantic",
|
||||
"hybrid",
|
||||
}
|
||||
if effective_search_type == "text":
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.FTS
|
||||
elif effective_search_type in ("vector", "semantic"):
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif effective_search_type == "hybrid":
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif effective_search_type == "title":
|
||||
search_query.title = effective_query
|
||||
elif effective_search_type == "permalink" and "*" in effective_query:
|
||||
search_query.permalink_match = effective_query
|
||||
elif effective_search_type == "permalink":
|
||||
search_query.permalink = effective_query
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid search_type '{effective_search_type}'. "
|
||||
f"Valid options: {', '.join(sorted(valid_search_types))}"
|
||||
)
|
||||
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if note_types:
|
||||
search_query.note_types = note_types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
# Alias common column/model names to their frontmatter key equivalents.
|
||||
# Users often pass "note_type" (the entity model column) when the
|
||||
# frontmatter field is actually "type".
|
||||
_METADATA_KEY_ALIASES = {"note_type": "type"}
|
||||
metadata_filters = {
|
||||
_METADATA_KEY_ALIASES.get(k, k): v for k, v in metadata_filters.items()
|
||||
}
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
if min_similarity is not None:
|
||||
search_query.min_similarity = min_similarity
|
||||
|
||||
# Reject searches with no criteria at all
|
||||
if search_query.no_criteria():
|
||||
return (
|
||||
"# No Search Criteria\n\n"
|
||||
"Please provide at least one of: `query`, `metadata_filters`, "
|
||||
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
|
||||
# Only map search_type to query fields when there is an actual query string.
|
||||
# When query is None/empty, skip the search mode block — filters-only path.
|
||||
effective_query = (query or "").strip()
|
||||
if effective_query:
|
||||
valid_search_types = {
|
||||
"text",
|
||||
"title",
|
||||
"permalink",
|
||||
"vector",
|
||||
"semantic",
|
||||
"hybrid",
|
||||
}
|
||||
if effective_search_type == "text":
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.FTS
|
||||
elif effective_search_type in ("vector", "semantic"):
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
|
||||
elif effective_search_type == "hybrid":
|
||||
search_query.text = effective_query
|
||||
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
|
||||
elif effective_search_type == "title":
|
||||
search_query.title = effective_query
|
||||
elif effective_search_type == "permalink" and "*" in effective_query:
|
||||
search_query.permalink_match = effective_query
|
||||
elif effective_search_type == "permalink":
|
||||
search_query.permalink = effective_query
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid search_type '{effective_search_type}'. "
|
||||
f"Valid options: {', '.join(sorted(valid_search_types))}"
|
||||
)
|
||||
|
||||
# Default to entity-level results to avoid returning individual
|
||||
# observations/relations as separate search results (see issue #31).
|
||||
# Applied after no_criteria() so that the implicit default doesn't
|
||||
# mask a truly empty search request.
|
||||
if not search_query.entity_types:
|
||||
search_query.entity_types = [SearchItemType("entity")]
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if note_types:
|
||||
search_query.note_types = note_types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
# Alias common column/model names to their frontmatter key equivalents.
|
||||
# Users often pass "note_type" (the entity model column) when the
|
||||
# frontmatter field is actually "type".
|
||||
_METADATA_KEY_ALIASES = {"note_type": "type"}
|
||||
metadata_filters = {
|
||||
_METADATA_KEY_ALIASES.get(k, k): v for k, v in metadata_filters.items()
|
||||
}
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
if min_similarity is not None:
|
||||
search_query.min_similarity = min_similarity
|
||||
|
||||
# Reject searches with no criteria at all
|
||||
if search_query.no_criteria():
|
||||
return (
|
||||
"# No Search Criteria\n\n"
|
||||
"Please provide at least one of: `query`, `metadata_filters`, "
|
||||
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
|
||||
)
|
||||
|
||||
# Default to entity-level results to avoid returning individual
|
||||
# observations/relations as separate search results (see issue #31).
|
||||
# Applied after no_criteria() so that the implicit default doesn't
|
||||
# mask a truly empty search request.
|
||||
if not search_query.entity_types:
|
||||
search_query.entity_types = [SearchItemType("entity")]
|
||||
|
||||
logger.debug(
|
||||
f"Search request: project={active_project.name} "
|
||||
f"search_type={effective_search_type} "
|
||||
f"query={effective_query or '<filters-only>'} "
|
||||
f"note_types={len(note_types)} entity_types={len(search_query.entity_types or [])} "
|
||||
f"page={page} page_size={page_size}"
|
||||
)
|
||||
# Import here to avoid circular import (tools → clients → utils → tools)
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
# Use typed SearchClient for API calls
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
result = await search_client.search(
|
||||
search_query.model_dump(),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
logger.debug(
|
||||
f"Search response: project={active_project.name} "
|
||||
f"results={len(result.results)} has_more={str(result.has_more).lower()} "
|
||||
f"page={result.current_page} page_size={result.page_size}"
|
||||
)
|
||||
|
||||
# Check if we got no results and provide helpful guidance
|
||||
if not result.results:
|
||||
logger.debug(
|
||||
f"Search request: project={active_project.name} "
|
||||
f"search_type={effective_search_type} "
|
||||
f"query={effective_query or '<filters-only>'} "
|
||||
f"note_types={len(note_types)} entity_types={len(search_query.entity_types or [])} "
|
||||
f"page={page} page_size={page_size}"
|
||||
f"Search returned no results for query: {query} in project {active_project.name}"
|
||||
)
|
||||
# Import here to avoid circular import (tools → clients → utils → tools)
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
|
||||
# Use typed SearchClient for API calls
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
result = await search_client.search(
|
||||
search_query.model_dump(),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
logger.debug(
|
||||
f"Search response: project={active_project.name} "
|
||||
f"results={len(result.results)} has_more={str(result.has_more).lower()} "
|
||||
f"page={result.current_page} page_size={result.page_size}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return result.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
# Check if we got no results and provide helpful guidance
|
||||
if not result.results:
|
||||
logger.debug(
|
||||
f"Search returned no results for query: {query} in project {active_project.name}"
|
||||
)
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
return _format_search_markdown(result, active_project.name, query)
|
||||
|
||||
if output_format == "json":
|
||||
return result.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
return _format_search_markdown(result, active_project.name, query)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Search failed for query '{query or ''}': {e}, project: {active_project.name}"
|
||||
)
|
||||
# Return formatted error message as string for better user experience
|
||||
return _format_search_error_response(
|
||||
active_project.name, str(e), query or "", effective_search_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Search failed for query '{query or ''}': {e}, project: {active_project.name}"
|
||||
)
|
||||
# Return formatted error message as string for better user experience
|
||||
return _format_search_error_response(
|
||||
active_project.name, str(e), query or "", effective_search_type
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ def _text_block(message: str) -> List[ContentBlock]:
|
||||
async def search_notes_ui(
|
||||
query: str,
|
||||
project: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: Optional[str] = None,
|
||||
@@ -49,6 +50,7 @@ async def search_notes_ui(
|
||||
result = await search_notes(
|
||||
query=query,
|
||||
project=project,
|
||||
project_id=project_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
@@ -97,16 +99,14 @@ async def search_notes_ui(
|
||||
async def read_note_ui(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
project_id: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a note preview UI as an embedded MCP-UI resource."""
|
||||
content = await read_note(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
project_id=project_id,
|
||||
output_format="text",
|
||||
context=context,
|
||||
)
|
||||
@@ -114,8 +114,6 @@ async def read_note_ui(
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"identifier": identifier,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": content,
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ to the Basic Memory API, with improved error handling and logging.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError
|
||||
import logfire
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError, Headers
|
||||
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
|
||||
from httpx._types import (
|
||||
RequestContent,
|
||||
@@ -24,7 +24,6 @@ from httpx._types import (
|
||||
from loguru import logger
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
@@ -41,43 +40,38 @@ def _classify_http_outcome(status_code: int) -> str:
|
||||
return "unknown" # pragma: no cover
|
||||
|
||||
|
||||
class _RequestSpan:
|
||||
"""Small adapter for attaching outcome metadata to a live request span."""
|
||||
def _response_span_attrs(response: Response) -> dict[str, Any]:
|
||||
"""Attributes to attach to a request span after a response lands."""
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"is_success": response.is_success,
|
||||
"outcome": _classify_http_outcome(response.status_code),
|
||||
}
|
||||
|
||||
def __init__(self, active_span: typing.Any | None):
|
||||
self._active_span = active_span
|
||||
|
||||
def record_response(self, response: Response) -> None:
|
||||
self._set_attributes(
|
||||
{
|
||||
"status_code": response.status_code,
|
||||
"is_success": response.is_success,
|
||||
"outcome": _classify_http_outcome(response.status_code),
|
||||
}
|
||||
)
|
||||
def _transport_error_span_attrs(exc: Exception) -> dict[str, Any]:
|
||||
"""Attributes to attach when the transport layer fails before any response."""
|
||||
return {
|
||||
"is_success": False,
|
||||
"outcome": "transport_error",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
|
||||
def record_transport_error(self, exc: Exception) -> None:
|
||||
self._set_attributes(
|
||||
{
|
||||
"is_success": False,
|
||||
"outcome": "transport_error",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
)
|
||||
|
||||
def _set_attributes(self, attrs: dict[str, typing.Any]) -> None:
|
||||
if self._active_span is None:
|
||||
return
|
||||
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
|
||||
|
||||
set_attributes = getattr(self._active_span, "set_attributes", None)
|
||||
if callable(set_attributes):
|
||||
set_attributes(attrs)
|
||||
return
|
||||
workspace_headers = workspace_permalink_headers()
|
||||
if not workspace_headers:
|
||||
return headers
|
||||
|
||||
set_attribute = getattr(self._active_span, "set_attribute", None)
|
||||
if callable(set_attribute):
|
||||
for key, value in attrs.items():
|
||||
set_attribute(key, value)
|
||||
if headers is None:
|
||||
return workspace_headers
|
||||
|
||||
merged_headers = Headers(headers)
|
||||
merged_headers.update(workspace_headers)
|
||||
return merged_headers
|
||||
|
||||
|
||||
def get_error_message(
|
||||
@@ -130,15 +124,20 @@ def get_error_message(
|
||||
return f"HTTP error {status_code}: {method} request to '{path}' failed"
|
||||
|
||||
|
||||
def _extract_response_data(response: Response) -> typing.Any:
|
||||
"""Safely decode response payload for error reporting."""
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
def _extract_response_data(response: Response) -> Any:
|
||||
"""Decode the JSON payload of an API response for error reporting.
|
||||
|
||||
Upstream gateways (Fly, Cloudflare, load balancers) can return HTML
|
||||
error pages before the request reaches our FastAPI app; those have no
|
||||
structured `detail` to surface, so we skip them. A malformed body with
|
||||
a JSON content-type is a server bug and we let it raise.
|
||||
"""
|
||||
if "application/json" not in response.headers.get("content-type", ""):
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
|
||||
def _response_detail_text(response_data: typing.Any) -> str | None:
|
||||
def _response_detail_text(response_data: Any) -> str | None:
|
||||
"""Extract textual error detail from API payloads."""
|
||||
if isinstance(response_data, dict):
|
||||
detail = response_data.get("detail")
|
||||
@@ -189,31 +188,6 @@ def _resolve_error_message(
|
||||
return get_error_message(status_code, url, method)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _request_scope(
|
||||
method: str,
|
||||
*,
|
||||
client_name: str | None,
|
||||
operation: str | None,
|
||||
path_template: str | None,
|
||||
params: QueryParamTypes | None = None,
|
||||
has_body: bool = False,
|
||||
):
|
||||
"""Create the shared MCP transport span used by all HTTP helpers."""
|
||||
attrs = {
|
||||
"method": method,
|
||||
"client_name": client_name,
|
||||
"operation": operation,
|
||||
"path_template": path_template,
|
||||
"phase": "request",
|
||||
"has_query": bool(params),
|
||||
"has_body": has_body,
|
||||
}
|
||||
with telemetry.contextualize(**attrs):
|
||||
with telemetry.started_span("mcp.http.request", **attrs) as active_span:
|
||||
yield _RequestSpan(active_span)
|
||||
|
||||
|
||||
async def call_get(
|
||||
client: AsyncClient,
|
||||
url: URL | str,
|
||||
@@ -250,27 +224,30 @@ async def call_get(
|
||||
"""
|
||||
logger.debug(f"Calling GET '{url}' params: '{params}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
request_span: logfire.LogfireSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
"GET",
|
||||
with logfire.span(
|
||||
"mcp.http.request",
|
||||
method="GET",
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=False,
|
||||
) as request_span:
|
||||
response = await client.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
request_span.set_attributes(_response_span_attrs(response))
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -299,7 +276,7 @@ async def call_get(
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
request_span.set_attributes(_transport_error_span_attrs(e))
|
||||
raise
|
||||
|
||||
|
||||
@@ -347,15 +324,17 @@ async def call_put(
|
||||
"""
|
||||
logger.debug(f"Calling PUT '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
request_span: logfire.LogfireSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
"PUT",
|
||||
with logfire.span(
|
||||
"mcp.http.request",
|
||||
method="PUT",
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
) as request_span:
|
||||
response = await client.put(
|
||||
@@ -365,14 +344,14 @@ async def call_put(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
request_span.set_attributes(_response_span_attrs(response))
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -402,7 +381,7 @@ async def call_put(
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
request_span.set_attributes(_transport_error_span_attrs(e))
|
||||
raise
|
||||
|
||||
|
||||
@@ -449,15 +428,17 @@ async def call_patch(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling PATCH '{url}'")
|
||||
request_span: _RequestSpan | None = None
|
||||
request_span: logfire.LogfireSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
"PATCH",
|
||||
with logfire.span(
|
||||
"mcp.http.request",
|
||||
method="PATCH",
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
) as request_span:
|
||||
response = await client.patch(
|
||||
@@ -467,14 +448,14 @@ async def call_patch(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
request_span.set_attributes(_response_span_attrs(response))
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -509,7 +490,7 @@ async def call_patch(
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
request_span.set_attributes(_transport_error_span_attrs(e))
|
||||
raise
|
||||
|
||||
|
||||
@@ -557,15 +538,17 @@ async def call_post(
|
||||
"""
|
||||
logger.debug(f"Calling POST '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
request_span: logfire.LogfireSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
"POST",
|
||||
with logfire.span(
|
||||
"mcp.http.request",
|
||||
method="POST",
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
) as request_span:
|
||||
response = await client.post(
|
||||
@@ -575,14 +558,14 @@ async def call_post(
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
request_span.set_attributes(_response_span_attrs(response))
|
||||
logger.debug(f"response: {_extract_response_data(response)}")
|
||||
|
||||
if response.is_success:
|
||||
@@ -612,7 +595,7 @@ async def call_post(
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
request_span.set_attributes(_transport_error_span_attrs(e))
|
||||
raise
|
||||
|
||||
|
||||
@@ -684,27 +667,30 @@ async def call_delete(
|
||||
"""
|
||||
logger.debug(f"Calling DELETE '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
request_span: logfire.LogfireSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
"DELETE",
|
||||
with logfire.span(
|
||||
"mcp.http.request",
|
||||
method="DELETE",
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=False,
|
||||
) as request_span:
|
||||
response = await client.delete(
|
||||
url=url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
headers=_request_headers(headers),
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
request_span.set_attributes(_response_span_attrs(response))
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -733,5 +719,5 @@ async def call_delete(
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
request_span.set_attributes(_transport_error_span_attrs(e))
|
||||
raise
|
||||
|
||||
@@ -17,9 +17,7 @@ from basic_memory.mcp.tools.read_note import read_note
|
||||
async def view_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
project_id: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""View a markdown note as a formatted artifact.
|
||||
@@ -32,8 +30,9 @@ async def view_note(
|
||||
identifier: The title or permalink of the note to view
|
||||
project: Project name to read from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
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().
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -46,9 +45,6 @@ async def view_note(
|
||||
# View a note by permalink
|
||||
view_note("meetings/weekly-standup")
|
||||
|
||||
# View with pagination
|
||||
view_note("large-document", page=2, page_size=5)
|
||||
|
||||
# Explicit project specification
|
||||
view_note("Meeting Notes", project="my-project")
|
||||
|
||||
@@ -63,9 +59,7 @@ async def view_note(
|
||||
await read_note(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
project_id=project_id,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,6 +6,42 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
|
||||
|
||||
|
||||
def _personal_workspace() -> WorkspaceInfo:
|
||||
"""Return a display-only personal workspace when discovery has no rows.
|
||||
|
||||
This keeps list_workspaces friendly for non-teams or local-only users. It is not
|
||||
the cloud routing source of truth; project-scoped routing still depends on real
|
||||
workspace discovery and the workspace project index in project_context.
|
||||
"""
|
||||
return WorkspaceInfo(
|
||||
tenant_id="personal",
|
||||
workspace_type="personal",
|
||||
slug="personal",
|
||||
name="Personal",
|
||||
role="owner",
|
||||
is_default=True,
|
||||
has_active_subscription=True,
|
||||
)
|
||||
|
||||
|
||||
def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListResponse:
|
||||
"""Build the structured MCP response from the shared cloud workspace schema."""
|
||||
if not workspaces:
|
||||
workspaces = [_personal_workspace()]
|
||||
|
||||
default_workspace_id = next(
|
||||
(workspace.tenant_id for workspace in workspaces if workspace.is_default),
|
||||
None,
|
||||
)
|
||||
return WorkspaceListResponse(
|
||||
workspaces=workspaces,
|
||||
count=len(workspaces),
|
||||
default_workspace_id=default_workspace_id,
|
||||
current_workspace_id=None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -24,40 +60,18 @@ async def list_workspaces(
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
"""
|
||||
workspaces = await get_available_workspaces(context=context)
|
||||
response = _workspace_list_response(workspaces)
|
||||
|
||||
if output_format == "json":
|
||||
return {
|
||||
"workspaces": [
|
||||
{
|
||||
"tenant_id": ws.tenant_id,
|
||||
"name": ws.name,
|
||||
"workspace_type": ws.workspace_type,
|
||||
"role": ws.role,
|
||||
"organization_id": ws.organization_id,
|
||||
"has_active_subscription": ws.has_active_subscription,
|
||||
}
|
||||
for ws in workspaces
|
||||
],
|
||||
"count": len(workspaces),
|
||||
}
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
if not workspaces:
|
||||
return (
|
||||
"# No Workspaces Available\n\n"
|
||||
"No accessible workspaces were found for this account. "
|
||||
"Ensure the account has an active subscription and tenant access."
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"# Available Workspaces ({len(workspaces)})",
|
||||
"",
|
||||
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
|
||||
"",
|
||||
]
|
||||
for workspace in workspaces:
|
||||
lines = [f"# Available Workspaces ({response.count})", ""]
|
||||
for workspace in response.workspaces:
|
||||
default_label = ", default" if workspace.is_default else ""
|
||||
lines.append(
|
||||
f"- {workspace.name} "
|
||||
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
|
||||
f"(slug={workspace.slug}, type={workspace.workspace_type}, "
|
||||
f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
import textwrap
|
||||
from typing import Annotated, List, Union, Optional, Literal
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic import AliasChoices, BeforeValidator, Field
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
|
||||
from basic_memory.utils import (
|
||||
build_qualified_permalink_reference,
|
||||
coerce_dict,
|
||||
parse_tags,
|
||||
validate_project_path,
|
||||
)
|
||||
from basic_memory.workspace_context import current_workspace_permalink_context
|
||||
|
||||
# Define TagType as a Union that can accept either a string or a list of strings or None
|
||||
TagType = Union[List[str], str, None]
|
||||
@@ -25,9 +31,13 @@ TagType = Union[List[str], str, None]
|
||||
async def write_note(
|
||||
title: str,
|
||||
content: str,
|
||||
directory: str,
|
||||
# Folder/dir/path are interchangeable in models' training data.
|
||||
directory: Annotated[
|
||||
str,
|
||||
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
|
||||
],
|
||||
project: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
tags: list[str] | str | None = None,
|
||||
note_type: str = "note",
|
||||
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
|
||||
@@ -58,10 +68,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]]`
|
||||
|
||||
@@ -74,6 +88,9 @@ async def write_note(
|
||||
project: Project name to write to. Optional - server will resolve using the
|
||||
hierarchy above. If unknown, use list_memory_projects() to discover
|
||||
available projects.
|
||||
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().
|
||||
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
||||
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
||||
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
|
||||
@@ -149,180 +166,183 @@ async def write_note(
|
||||
overwrite if overwrite is not None else ConfigManager().config.write_note_overwrite_default
|
||||
)
|
||||
|
||||
with telemetry.operation(
|
||||
with logfire.span(
|
||||
"mcp.tool.write_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="write_note",
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
requested_project_id=project_id,
|
||||
note_type=note_type,
|
||||
overwrite=effective_overwrite,
|
||||
output_format=output_format,
|
||||
):
|
||||
async with get_project_client(project, workspace, context) as (client, active_project):
|
||||
with telemetry.contextualize(
|
||||
project_name=active_project.name,
|
||||
workspace_id=workspace,
|
||||
tool_name="write_note",
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
|
||||
)
|
||||
async with get_project_client(project, context=context, project_id=project_id) as (
|
||||
client,
|
||||
active_project,
|
||||
):
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
|
||||
)
|
||||
|
||||
# Normalize "/" to empty string for root directory (must happen before validation)
|
||||
if directory == "/":
|
||||
directory = ""
|
||||
# Normalize "/" to empty string for root directory (must happen before validation)
|
||||
if directory == "/":
|
||||
directory = ""
|
||||
|
||||
# Validate directory path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if directory and not validate_project_path(directory, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
directory=directory,
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"action": "created",
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
|
||||
# Build entity_metadata from optional metadata, then explicit tags on top
|
||||
# Order matters: explicit tags parameter takes precedence over metadata["tags"]
|
||||
entity_metadata = {}
|
||||
if metadata:
|
||||
entity_metadata.update(metadata)
|
||||
if tag_list:
|
||||
entity_metadata["tags"] = tag_list
|
||||
|
||||
entity = Entity(
|
||||
title=title,
|
||||
# Validate directory path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if directory and not validate_project_path(directory, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
directory=directory,
|
||||
note_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=entity_metadata or None,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Try to create the entity first (optimistic create)
|
||||
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
||||
action = "Created" # Default to created
|
||||
try:
|
||||
result = await knowledge_client.create_entity(entity.model_dump(), fast=False)
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
# Guard: block overwrite unless explicitly enabled
|
||||
if not effective_overwrite:
|
||||
logger.warning(
|
||||
f"write_note blocked: note already exists (overwrite not enabled) "
|
||||
f"permalink={entity.permalink}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": entity.permalink,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"action": "conflict",
|
||||
"error": "NOTE_ALREADY_EXISTS",
|
||||
}
|
||||
return _format_overwrite_error(
|
||||
title, entity.permalink, active_project.name
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Entity exists, updating instead permalink={entity.permalink}"
|
||||
)
|
||||
try:
|
||||
if not entity.permalink:
|
||||
raise ValueError(
|
||||
"Entity permalink is required for updates"
|
||||
) # pragma: no cover
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
result = await knowledge_client.update_entity(
|
||||
entity_id, entity.model_dump(), fast=False
|
||||
)
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error # pragma: no cover
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise # pragma: no cover
|
||||
summary = [
|
||||
f"# {action} note",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
# Count observations by category
|
||||
categories = {}
|
||||
if result.observations:
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
# Count resolved/unresolved relations
|
||||
unresolved = 0
|
||||
resolved = 0
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append(
|
||||
"\nNote: Unresolved relations point to entities that don't exist yet."
|
||||
)
|
||||
summary.append(
|
||||
"They will be automatically resolved when target entities are created or during sync operations."
|
||||
)
|
||||
|
||||
if tag_list:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
||||
|
||||
# Log the response with structured data
|
||||
logger.info(
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
project=active_project.name,
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"action": action.lower(),
|
||||
"title": title,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"action": "created",
|
||||
"error": "SECURITY_VALIDATION_ERROR",
|
||||
}
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
|
||||
# Build entity_metadata from optional metadata, then explicit tags on top
|
||||
# Order matters: explicit tags parameter takes precedence over metadata["tags"]
|
||||
entity_metadata = {}
|
||||
if metadata:
|
||||
entity_metadata.update(metadata)
|
||||
if tag_list:
|
||||
entity_metadata["tags"] = tag_list
|
||||
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
note_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=entity_metadata or None,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Try to create the entity first (optimistic create)
|
||||
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
||||
action = "Created" # Default to created
|
||||
try:
|
||||
result = await knowledge_client.create_entity(entity.model_dump())
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
# Guard: block overwrite unless explicitly enabled
|
||||
if not effective_overwrite:
|
||||
logger.warning(
|
||||
f"write_note blocked: note already exists (overwrite not enabled) "
|
||||
f"permalink={entity.permalink}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": title,
|
||||
"permalink": entity.permalink,
|
||||
"file_path": None,
|
||||
"checksum": None,
|
||||
"action": "conflict",
|
||||
"error": "NOTE_ALREADY_EXISTS",
|
||||
}
|
||||
return _format_overwrite_error(title, entity.permalink, active_project.name)
|
||||
|
||||
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
|
||||
try:
|
||||
if not entity.permalink:
|
||||
raise ValueError(
|
||||
"Entity permalink is required for updates"
|
||||
) # pragma: no cover
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
result = await knowledge_client.update_entity(
|
||||
entity_id, entity.model_dump()
|
||||
)
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error # pragma: no cover
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise # pragma: no cover
|
||||
response_permalink = result.permalink
|
||||
workspace_context = current_workspace_permalink_context()
|
||||
if response_permalink and workspace_context is not None:
|
||||
response_permalink = build_qualified_permalink_reference(
|
||||
active_project.permalink,
|
||||
response_permalink,
|
||||
workspace_permalink=workspace_context.workspace_slug,
|
||||
)
|
||||
|
||||
summary = [
|
||||
f"# {action} note",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {response_permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
# Count observations by category
|
||||
categories = {}
|
||||
if result.observations:
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
# Count resolved/unresolved relations
|
||||
unresolved = 0
|
||||
resolved = 0
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append(
|
||||
"\nNote: Unresolved relations point to entities that don't exist yet."
|
||||
)
|
||||
summary.append(
|
||||
"They will be automatically resolved when target entities are created or during sync operations."
|
||||
)
|
||||
|
||||
if tag_list:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
||||
|
||||
# Log the response with structured data
|
||||
logger.info(
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={response_permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": response_permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"action": action.lower(),
|
||||
}
|
||||
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
|
||||
def _format_overwrite_error(title: str, permalink: str | None, project_name: str) -> str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Base model class for SQLAlchemy models."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -7,4 +9,5 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
"""Base class for all models"""
|
||||
|
||||
pass
|
||||
if TYPE_CHECKING:
|
||||
id: int
|
||||
|
||||
@@ -62,7 +62,7 @@ class Entity(Base):
|
||||
)
|
||||
|
||||
# Core identity
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True) # pyright: ignore [reportIncompatibleVariableOverride]
|
||||
# External UUID for API references - stable identifier that won't change
|
||||
external_id: Mapped[str] = mapped_column(String, unique=True, default=lambda: str(uuid.uuid4()))
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
@@ -229,7 +229,7 @@ class Observation(Base):
|
||||
Index("ix_observation_category", "category"), # Add category index
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True) # pyright: ignore [reportIncompatibleVariableOverride]
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
@@ -276,7 +276,7 @@ class Relation(Base):
|
||||
Index("ix_relation_to_id", "to_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True) # pyright: ignore [reportIncompatibleVariableOverride]
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
||||
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
||||
to_id: Mapped[Optional[int]] = mapped_column(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import os
|
||||
from threading import Lock
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.config import BasicMemoryConfig, default_fastembed_cache_dir
|
||||
from basic_memory.repository.embedding_provider import EmbeddingProvider
|
||||
|
||||
type ProviderCacheKey = tuple[
|
||||
@@ -12,7 +12,7 @@ type ProviderCacheKey = tuple[
|
||||
int | None,
|
||||
int,
|
||||
int,
|
||||
str | None,
|
||||
str,
|
||||
int | None,
|
||||
int | None,
|
||||
]
|
||||
@@ -22,6 +22,20 @@ _EMBEDDING_PROVIDER_CACHE_LOCK = Lock()
|
||||
_FASTEMBED_MAX_THREADS = 8
|
||||
|
||||
|
||||
def _resolve_cache_dir(app_config: BasicMemoryConfig) -> str:
|
||||
"""Resolve the effective FastEmbed cache dir for this config.
|
||||
|
||||
Uses an explicit ``is not None`` check — an empty string override from
|
||||
config or ``BASIC_MEMORY_SEMANTIC_EMBEDDING_CACHE_DIR`` is an invalid
|
||||
path, not a request to fall back to the default, and FastEmbed's error
|
||||
message is clearer than silently swapping in a different directory.
|
||||
"""
|
||||
configured = app_config.semantic_embedding_cache_dir
|
||||
if configured is not None:
|
||||
return configured
|
||||
return default_fastembed_cache_dir()
|
||||
|
||||
|
||||
def _available_cpu_count() -> int | None:
|
||||
"""Return the CPU budget available to this process when the runtime exposes it."""
|
||||
process_cpu_count = getattr(os, "process_cpu_count", None)
|
||||
@@ -61,7 +75,12 @@ def _resolve_fastembed_runtime_knobs(
|
||||
|
||||
|
||||
def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
|
||||
"""Build a stable cache key from provider-relevant semantic embedding config."""
|
||||
"""Build a stable cache key from provider-relevant semantic embedding config.
|
||||
|
||||
Uses the *resolved* cache dir — not the raw config field — so different
|
||||
FASTEMBED_CACHE_PATH values produce distinct cache keys even when the
|
||||
config field itself is unset.
|
||||
"""
|
||||
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
|
||||
return (
|
||||
app_config.semantic_embedding_provider.strip().lower(),
|
||||
@@ -69,7 +88,7 @@ def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
|
||||
app_config.semantic_embedding_dimensions,
|
||||
app_config.semantic_embedding_batch_size,
|
||||
app_config.semantic_embedding_request_concurrency,
|
||||
app_config.semantic_embedding_cache_dir,
|
||||
_resolve_cache_dir(app_config),
|
||||
resolved_threads,
|
||||
resolved_parallel,
|
||||
)
|
||||
@@ -103,8 +122,12 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide
|
||||
from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider
|
||||
|
||||
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
|
||||
if app_config.semantic_embedding_cache_dir is not None:
|
||||
extra_kwargs["cache_dir"] = app_config.semantic_embedding_cache_dir
|
||||
# Trigger: cache_dir is resolved rather than passed through directly.
|
||||
# Why: FastEmbed's own default caches to <system tmp>/fastembed_cache,
|
||||
# which disappears in sandboxed MCP runtimes (e.g. Codex CLI). See #741.
|
||||
# Outcome: always pass an explicit, user-writable cache dir so the ONNX
|
||||
# model persists across runs.
|
||||
extra_kwargs["cache_dir"] = _resolve_cache_dir(app_config)
|
||||
if resolved_threads is not None:
|
||||
extra_kwargs["threads"] = resolved_threads
|
||||
if resolved_parallel is not None:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ class EntityRepository(Repository[Entity]):
|
||||
"""
|
||||
super().__init__(session_maker, Entity, project_id=project_id)
|
||||
|
||||
async def get_by_id(self, entity_id: int) -> Optional[Entity]: # pragma: no cover
|
||||
async def get_by_id(self, entity_id: int, *, load_relations: bool = True) -> Optional[Entity]:
|
||||
"""Get entity by numeric ID.
|
||||
|
||||
Args:
|
||||
@@ -43,6 +43,10 @@ class EntityRepository(Repository[Entity]):
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
if not load_relations:
|
||||
result = await session.execute(self.select().where(Entity.id == entity_id))
|
||||
return result.scalars().one_or_none()
|
||||
|
||||
return await self.select_by_id(session, entity_id)
|
||||
|
||||
async def _find_one_by_query(self, query, *, load_relations: bool) -> Optional[Entity]:
|
||||
@@ -174,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.
|
||||
|
||||
@@ -432,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.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from basic_memory.repository.embedding_provider import EmbeddingProvider
|
||||
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastembed import TextEmbedding # type: ignore[import-not-found] # pragma: no cover
|
||||
from fastembed import TextEmbedding # pragma: no cover
|
||||
|
||||
|
||||
class FastEmbedEmbeddingProvider(EmbeddingProvider):
|
||||
@@ -62,7 +62,7 @@ class FastEmbedEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
def _create_model() -> "TextEmbedding":
|
||||
try:
|
||||
from fastembed import TextEmbedding # type: ignore[import-not-found]
|
||||
from fastembed import TextEmbedding
|
||||
except (
|
||||
ImportError
|
||||
) as exc: # pragma: no cover - exercised via tests with monkeypatch
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -50,7 +50,7 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
return self._client
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI # type: ignore[import-not-found]
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError as exc: # pragma: no cover - covered via monkeypatch tests
|
||||
raise SemanticDependenciesMissingError(
|
||||
"OpenAI dependency is missing. "
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import (
|
||||
Result,
|
||||
and_,
|
||||
delete,
|
||||
update as sqlalchemy_update,
|
||||
)
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
@@ -140,6 +141,20 @@ class Repository[T: Base]:
|
||||
# Query within same session
|
||||
return await self.select_by_ids(session, [m.id for m in models]) # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
async def add_all_no_return(self, models: List[T]) -> int:
|
||||
"""Insert models without reloading them afterward."""
|
||||
if not models:
|
||||
return 0
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
for model in models:
|
||||
self._set_project_id_if_needed(model)
|
||||
|
||||
session.add_all(models)
|
||||
await session.flush()
|
||||
logger.debug(f"Added {len(models)} {self.Model.__name__} records")
|
||||
return len(models)
|
||||
|
||||
def select(self, *entities: Any) -> Select:
|
||||
"""Create a new SELECT statement.
|
||||
|
||||
@@ -268,7 +283,7 @@ class Repository[T: Base]:
|
||||
|
||||
return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
async def update(self, entity_id: int, entity_data: dict | T) -> Optional[T]:
|
||||
async def update(self, entity_id: int, entity_data: dict[str, Any] | T) -> Optional[T]:
|
||||
"""Update an entity with the given data."""
|
||||
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
@@ -279,12 +294,13 @@ class Repository[T: Base]:
|
||||
entity = result.scalars().one()
|
||||
|
||||
if isinstance(entity_data, dict):
|
||||
for key, value in entity_data.items():
|
||||
if key in self.valid_columns:
|
||||
setattr(entity, key, value)
|
||||
update_data = cast(dict[str, Any], entity_data)
|
||||
for key in self.valid_columns:
|
||||
if key in update_data:
|
||||
setattr(entity, key, update_data[key])
|
||||
|
||||
elif isinstance(entity_data, self.Model):
|
||||
for column in self.Model.__table__.columns.keys():
|
||||
for column in self.valid_columns:
|
||||
setattr(entity, column, getattr(entity_data, column))
|
||||
|
||||
await session.flush() # Make sure changes are flushed
|
||||
@@ -297,6 +313,25 @@ class Repository[T: Base]:
|
||||
logger.debug(f"No {self.Model.__name__} found to update: {entity_id}")
|
||||
return None
|
||||
|
||||
async def update_fields(self, entity_id: Any, entity_data: dict[str, Any]) -> bool:
|
||||
"""Update columns without reloading the model graph afterward."""
|
||||
update_data = {k: v for k, v in entity_data.items() if k in self.valid_columns}
|
||||
if not update_data:
|
||||
return True
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
conditions = [self.primary_key == entity_id]
|
||||
if self.has_project_id and self.project_id is not None:
|
||||
conditions.append(getattr(self.Model, "project_id") == self.project_id)
|
||||
|
||||
result = cast(
|
||||
CursorResult[Any],
|
||||
await session.execute(
|
||||
sqlalchemy_update(self.Model).where(and_(*conditions)).values(**update_data)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete(self, entity_id: int) -> bool:
|
||||
"""Delete an entity from the database."""
|
||||
logger.debug(f"Deleting {self.Model.__name__}: {entity_id}")
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -12,11 +12,12 @@ from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db, telemetry
|
||||
from basic_memory import db
|
||||
from basic_memory.repository.embedding_provider import EmbeddingProvider
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.semantic_errors import (
|
||||
@@ -246,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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -845,7 +864,7 @@ class SearchRepositoryBase(ABC):
|
||||
progress_callback(entity_id, completed_entities, total_entities)
|
||||
|
||||
prepare_window_size = self._vector_prepare_window_size()
|
||||
with telemetry.started_span(
|
||||
with logfire.span(
|
||||
"basic_memory.vector_sync.batch",
|
||||
project_id=self.project_id,
|
||||
backend=backend_name,
|
||||
@@ -1068,39 +1087,42 @@ class SearchRepositoryBase(ABC):
|
||||
write_seconds_total=result.write_seconds_total,
|
||||
)
|
||||
batch_total_seconds = time.perf_counter() - batch_start
|
||||
metric_attrs = {
|
||||
batch_attrs = {
|
||||
"backend": backend_name,
|
||||
"skip_only_batch": result.embedding_jobs_total == 0,
|
||||
}
|
||||
telemetry.record_histogram(
|
||||
"vector_sync_batch_total_seconds",
|
||||
batch_total_seconds,
|
||||
unit="s",
|
||||
**metric_attrs,
|
||||
logfire.metric_histogram("vector_sync_batch_total_seconds", unit="s").record(
|
||||
batch_total_seconds, attributes=batch_attrs
|
||||
)
|
||||
telemetry.add_counter(
|
||||
"vector_sync_entities_total", result.entities_total, **metric_attrs
|
||||
logfire.metric_histogram("vector_sync_prepare_seconds", unit="s").record(
|
||||
result.prepare_seconds_total, attributes=batch_attrs
|
||||
)
|
||||
telemetry.add_counter(
|
||||
"vector_sync_entities_skipped",
|
||||
result.entities_skipped,
|
||||
**metric_attrs,
|
||||
logfire.metric_histogram("vector_sync_queue_wait_seconds", unit="s").record(
|
||||
result.queue_wait_seconds_total, attributes=batch_attrs
|
||||
)
|
||||
telemetry.add_counter(
|
||||
"vector_sync_entities_deferred",
|
||||
result.entities_deferred,
|
||||
**metric_attrs,
|
||||
logfire.metric_histogram("vector_sync_embed_seconds", unit="s").record(
|
||||
result.embed_seconds_total, attributes=batch_attrs
|
||||
)
|
||||
telemetry.add_counter(
|
||||
"vector_sync_embedding_jobs_total",
|
||||
result.embedding_jobs_total,
|
||||
**metric_attrs,
|
||||
logfire.metric_histogram("vector_sync_write_seconds", unit="s").record(
|
||||
result.write_seconds_total, attributes=batch_attrs
|
||||
)
|
||||
telemetry.add_counter("vector_sync_chunks_total", result.chunks_total, **metric_attrs)
|
||||
telemetry.add_counter(
|
||||
"vector_sync_chunks_skipped",
|
||||
result.chunks_skipped,
|
||||
**metric_attrs,
|
||||
logfire.metric_counter("vector_sync_entities_total").add(
|
||||
result.entities_total, attributes=batch_attrs
|
||||
)
|
||||
logfire.metric_counter("vector_sync_entities_skipped").add(
|
||||
result.entities_skipped, attributes=batch_attrs
|
||||
)
|
||||
logfire.metric_counter("vector_sync_entities_deferred").add(
|
||||
result.entities_deferred, attributes=batch_attrs
|
||||
)
|
||||
logfire.metric_counter("vector_sync_embedding_jobs_total").add(
|
||||
result.embedding_jobs_total, attributes=batch_attrs
|
||||
)
|
||||
logfire.metric_counter("vector_sync_chunks_total").add(
|
||||
result.chunks_total, attributes=batch_attrs
|
||||
)
|
||||
logfire.metric_counter("vector_sync_chunks_skipped").add(
|
||||
result.chunks_skipped, attributes=batch_attrs
|
||||
)
|
||||
if batch_span is not None:
|
||||
batch_span.set_attributes(
|
||||
@@ -1673,36 +1695,12 @@ class SearchRepositoryBase(ABC):
|
||||
shard_count: int,
|
||||
remaining_jobs_after_shard: int,
|
||||
) -> None:
|
||||
"""Log completion and slow-entity warnings with a consistent format."""
|
||||
backend_name = type(self).__name__.removesuffix("SearchRepository").lower()
|
||||
metric_attrs = {
|
||||
"backend": backend_name,
|
||||
"skip_only_entity": entity_skipped and embedding_jobs_count == 0,
|
||||
}
|
||||
telemetry.record_histogram(
|
||||
"vector_sync_prepare_seconds",
|
||||
prepare_seconds,
|
||||
unit="s",
|
||||
**metric_attrs,
|
||||
)
|
||||
telemetry.record_histogram(
|
||||
"vector_sync_queue_wait_seconds",
|
||||
queue_wait_seconds,
|
||||
unit="s",
|
||||
**metric_attrs,
|
||||
)
|
||||
telemetry.record_histogram(
|
||||
"vector_sync_embed_seconds",
|
||||
embed_seconds,
|
||||
unit="s",
|
||||
**metric_attrs,
|
||||
)
|
||||
telemetry.record_histogram(
|
||||
"vector_sync_write_seconds",
|
||||
write_seconds,
|
||||
unit="s",
|
||||
**metric_attrs,
|
||||
)
|
||||
"""Log completion and slow-entity warnings with a consistent format.
|
||||
|
||||
Per-entity timings are aggregated into `VectorSyncBatchResult` and
|
||||
recorded as batch-level histograms once the batch completes — this
|
||||
function stays on the per-entity hot path so it only emits logs.
|
||||
"""
|
||||
if total_seconds > 10:
|
||||
logger.warning(
|
||||
"Vector sync slow entity: project_id={project_id} entity_id={entity_id} "
|
||||
|
||||
@@ -94,9 +94,22 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
raise e
|
||||
|
||||
# Fail fast: create vector tables at startup so missing sqlite-vec
|
||||
# or embedding provider errors surface immediately
|
||||
# or embedding provider errors surface immediately.
|
||||
# Trigger: the runtime semantic stack (sqlite-vec extension or embedding
|
||||
# provider) is unavailable at startup.
|
||||
# Why: failing the whole MCP boot for a search-only feature blocks
|
||||
# Claude Desktop's handshake (#711). Keyword-only search is a
|
||||
# reasonable fallback while the user resolves the dependency.
|
||||
# Outcome: log the cause, mark this repository as semantic-disabled so
|
||||
# downstream calls short-circuit cleanly, and let init complete.
|
||||
if self._semantic_enabled:
|
||||
await self._ensure_vector_tables()
|
||||
try:
|
||||
await self._ensure_vector_tables()
|
||||
except SemanticDependenciesMissingError as exc:
|
||||
logger.warning(
|
||||
f"Semantic search disabled: {exc}. Falling back to keyword-only search."
|
||||
)
|
||||
self._semantic_enabled = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FTS5 query preparation (backend-specific)
|
||||
@@ -350,7 +363,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
pass
|
||||
|
||||
try:
|
||||
import sqlite_vec # type: ignore[import-not-found]
|
||||
import sqlite_vec
|
||||
except ImportError as exc:
|
||||
raise SemanticDependenciesMissingError(
|
||||
"sqlite-vec package is missing. "
|
||||
@@ -374,6 +387,25 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
async_connection = await session.connection()
|
||||
raw_connection = await async_connection.get_raw_connection()
|
||||
driver_connection = raw_connection.driver_connection
|
||||
|
||||
# Trigger: the underlying CPython was built without sqlite extension support.
|
||||
# Why: python.org's macOS installer ships a stripped sqlite3 module with no
|
||||
# enable_load_extension; when uvx happens to pick that interpreter (#711),
|
||||
# the AttributeError surfaces here and previously crashed startup before
|
||||
# Claude Desktop could complete its MCP handshake.
|
||||
# Outcome: convert to SemanticDependenciesMissingError so the init-time
|
||||
# handler can degrade gracefully to keyword search instead of dying.
|
||||
if not hasattr(driver_connection, "enable_load_extension"):
|
||||
raise SemanticDependenciesMissingError(
|
||||
"This Python build does not support SQLite extension loading "
|
||||
"(no enable_load_extension on sqlite3.Connection). "
|
||||
"Common cause: python.org Python on macOS. "
|
||||
"Reinstall basic-memory under a Python that ships extension "
|
||||
"support (uv-managed CPython, Homebrew Python, or the official "
|
||||
"Docker image), or set semantic_search_enabled=false in config "
|
||||
"to silence this and use keyword-only search."
|
||||
)
|
||||
|
||||
await driver_connection.enable_load_extension(True)
|
||||
await driver_connection.load_extension(sqlite_vec.loadable_path())
|
||||
await driver_connection.enable_load_extension(False)
|
||||
@@ -588,6 +620,25 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def drop_vector_tables(self) -> None:
|
||||
"""Drop SQLite vector tables on a sqlite-vec-enabled connection."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
vector_sql_result = await session.execute(
|
||||
text(
|
||||
"SELECT sql FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
|
||||
)
|
||||
)
|
||||
vector_sql = vector_sql_result.scalar()
|
||||
if vector_sql and "using vec0" in vector_sql.lower():
|
||||
await self._ensure_sqlite_vec_loaded(session)
|
||||
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_index"))
|
||||
await session.commit()
|
||||
self._vector_tables_initialized = False
|
||||
|
||||
async def delete_stale_vector_rows(self) -> None:
|
||||
"""Delete vector rows whose source entities no longer exist."""
|
||||
await self._ensure_vector_tables()
|
||||
@@ -669,7 +720,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,
|
||||
@@ -679,31 +734,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 = {}
|
||||
@@ -776,7 +808,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"
|
||||
@@ -879,13 +912,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,
|
||||
@@ -918,7 +998,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 []
|
||||
@@ -956,3 +1036,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(
|
||||
|
||||
@@ -63,8 +63,10 @@ class WorkspaceInfo(BaseModel):
|
||||
|
||||
tenant_id: str = Field(..., description="Workspace tenant identifier")
|
||||
workspace_type: str = Field(..., description="Workspace type (personal or organization)")
|
||||
slug: str = Field(..., description="Stable workspace slug for qualified project routing")
|
||||
name: str = Field(..., description="Workspace display name")
|
||||
role: str = Field(..., description="Current user's role in the workspace")
|
||||
is_default: bool = Field(..., description="Whether this is the default cloud workspace")
|
||||
organization_id: str | None = Field(None, description="Organization ID for org workspaces")
|
||||
has_active_subscription: bool = Field(
|
||||
default=False, description="Whether the workspace has an active subscription"
|
||||
@@ -78,11 +80,59 @@ class WorkspaceListResponse(BaseModel):
|
||||
default_factory=list, description="Available workspaces"
|
||||
)
|
||||
count: int = Field(default=0, description="Number of available workspaces")
|
||||
default_workspace_id: str | None = Field(
|
||||
default=None, description="Default workspace tenant ID when available"
|
||||
)
|
||||
current_workspace_id: str | None = Field(
|
||||
default=None, description="Current workspace tenant ID when available"
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ MemoryUrl = Annotated[
|
||||
memory_url = TypeAdapter(MemoryUrl)
|
||||
|
||||
|
||||
def memory_url_path(url: memory_url) -> str: # pyright: ignore
|
||||
def memory_url_path(url: str) -> str:
|
||||
"""
|
||||
Returns the uri for a url value by removing the prefix "memory://" from a given MemoryUrl.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user