Compare commits

..

2 Commits

Author SHA1 Message Date
phernandez a3e4badcf9 fix(core): preserve empty frontmatter permalink semantics
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-13 09:54:44 -05:00
phernandez 58dd6963bd chore(core): use ty for typechecking
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-13 01:42:22 -05:00
215 changed files with 6990 additions and 22501 deletions
+1
View File
@@ -3,6 +3,7 @@
"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"
},
+1 -4
View File
@@ -10,9 +10,6 @@ on:
# - "src/**/*.js"
# - "src/**/*.jsx"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude-review:
# Only run for organization members and collaborators
@@ -30,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1
+2 -5
View File
@@ -4,9 +4,6 @@ on:
issues:
types: [opened]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
triage:
runs-on: ubuntu-latest
@@ -15,7 +12,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -71,4 +68,4 @@ jobs:
Read the issue carefully and provide helpful triage with appropriate labels.
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
+2 -4
View File
@@ -12,9 +12,6 @@ on:
pull_request_target:
types: [opened, synchronize]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude:
if: |
@@ -44,7 +41,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
# For pull_request_target, checkout the PR head to review the actual changes
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
@@ -68,3 +65,4 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
+3 -6
View File
@@ -5,9 +5,6 @@ on:
branches: [main]
workflow_dispatch: # Allow manual triggering
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
dev-release:
runs-on: ubuntu-latest
@@ -16,12 +13,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -53,4 +50,4 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true # Don't fail if version already exists
skip-existing: true # Don't fail if version already exists
+6 -6
View File
@@ -9,7 +9,6 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: basicmachines-co/basic-memory
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
docker:
@@ -20,17 +19,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -38,7 +37,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -49,7 +48,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
@@ -59,3 +58,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -5
View File
@@ -7,14 +7,11 @@ on:
- edited
- synchronize
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -41,4 +38,4 @@ jobs:
deps
installer
# Allow breaking changes (needs "!" after type/scope)
requireScopeForBreakingChange: true
requireScopeForBreakingChange: true
+22 -62
View File
@@ -5,9 +5,6 @@ on:
tags:
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release:
runs-on: ubuntu-latest
@@ -16,12 +13,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -42,7 +39,7 @@ jobs:
echo "Build completed successfully"
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.whl
@@ -63,63 +60,26 @@ jobs:
# Only run for stable releases (not dev, beta, or rc versions)
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
permissions:
contents: read
contents: write
actions: read
steps:
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
# with the bearer token and expects a 302 redirect. GitHub now returns
# 303 on that endpoint when authenticated, which the action treats as a
# fatal error. Re-implementing the bump as plain git+sed keeps the same
# contract (update url + sha256, commit, push) with no third-party action.
- name: Update Homebrew formula
uses: mislav/bump-homebrew-formula-action@v3
with:
# Formula name in homebrew-basic-memory repo
formula-name: basic-memory
# The tap repository
homebrew-tap: basicmachines-co/homebrew-basic-memory
# Base branch of the tap repository
base-branch: main
# Download URL will be automatically constructed from the tag
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
# Commit message for the formula update
commit-message: |
{{formulaName}} {{version}}
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
env:
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
REF: ${{ github.ref_name }}
REPO: ${{ github.repository }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Personal Access Token with repo scope for homebrew-basic-memory repo
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
VERSION="${REF#v}"
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
echo "::group::Compute tarball sha256"
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
test -n "$SHA256"
echo "sha256: $SHA256"
echo "::endgroup::"
echo "::group::Clone tap"
git clone \
--depth 1 \
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
tap
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "::endgroup::"
echo "::group::Patch Formula/basic-memory.rb"
# Pipe-delimited sed because the URL contains slashes. The Formula
# only has one `url` and one `sha256` directive, so a first-match
# replacement is unambiguous. POSIX character classes ([[:space:]])
# keep this portable across BSD and GNU sed.
sed -i -E \
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
Formula/basic-memory.rb
git --no-pager diff Formula/basic-memory.rb
echo "::endgroup::"
if git diff --quiet Formula/basic-memory.rb; then
echo "Formula already at ${REF}; nothing to do."
exit 0
fi
echo "::group::Commit & push"
git add Formula/basic-memory.rb
git commit -m "basic-memory ${VERSION}
Created by ${RUN_URL}"
git push origin HEAD:main
echo "::endgroup::"
+22 -27
View File
@@ -5,14 +5,9 @@ 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:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
pull_request:
branches: [ "main" ]
jobs:
static-checks:
@@ -21,12 +16,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: "3.12"
cache: "pip"
@@ -35,7 +30,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -55,7 +50,7 @@ jobs:
test-sqlite-unit:
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -71,12 +66,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -85,7 +80,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -117,12 +112,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -131,7 +126,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -147,7 +142,7 @@ jobs:
test-postgres-unit:
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 60
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -174,12 +169,12 @@ jobs:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -188,7 +183,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -231,12 +226,12 @@ jobs:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -245,7 +240,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -265,12 +260,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: "3.12"
cache: "pip"
@@ -279,7 +274,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v4
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
+5 -41
View File
@@ -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; only tests affected by changed code)
- Run impacted tests: `just testmon` (pytest-testmon)
- Run MCP smoke test: `just test-smoke`
- Fast local loop: `just fast-check` (default iteration flow)
- Fast local loop: `just fast-check`
- 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 ty check src tests test-int`
- Type check (pyright): `just typecheck-pyright` or `uv run pyright`
- Type check: `just typecheck` or `uv run pyright`
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
- 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,23 +48,12 @@ 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 + pytest-testmon impacted tests for changed code).
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
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)
@@ -253,31 +242,6 @@ 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
+6 -153
View File
@@ -1,153 +1,6 @@
# CHANGELOG
## 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.
## Unreleased
## v0.20.3 (2026-03-26)
@@ -2272,12 +2125,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: environment typo Signed-off-by: Ikko Eltociear Ashimine
fix spelling in CLAUDE.md: enviroment -> environment Signed-off-by: Ikko Eltociear Ashimine
<eltociear@gmail.com>
### Refactoring
- Move project stats into project subcommand
- Move project stats into projct subcommand
([`2a881b1`](https://github.com/basicmachines-co/basic-memory/commit/2a881b1425c73947f037fbe7ac5539c015b62526))
Signed-off-by: phernandez <paul@basicmachines.co>
@@ -2706,7 +2559,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Refix virtual env in installer build
- Refix vitual env in installer build
([`052f491`](https://github.com/basicmachines-co/basic-memory/commit/052f491fff629e8ead629c9259f8cb46c608d584))
@@ -2725,7 +2578,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Fix path to installer app artifact
- Fix path to intaller app artifact
([`53d220d`](https://github.com/basicmachines-co/basic-memory/commit/53d220df585561f9edd0d49a9e88f1d4055059cf))
@@ -2733,7 +2586,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Activate virtualenv in installer build
- Activate vitualenv in installer build
([`d4c8293`](https://github.com/basicmachines-co/basic-memory/commit/d4c8293687a52eaf3337fe02e2f7b80e4cc9a1bb))
- Trigger installer build on release
+34
View File
@@ -224,6 +224,40 @@ 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:
+577 -452
View File
File diff suppressed because it is too large Load Diff
+2 -67
View File
@@ -8,71 +8,6 @@
## Reporting a Vulnerability
If you find a vulnerability, please contact hello@basicmachines.co.
Use this section to tell people how to report a vulnerability.
Please do not open a public GitHub issue for security vulnerabilities. We aim
to respond within 72 hours and will coordinate a fix and disclosure timeline
with you.
## Threat Model
Basic Memory is a local-first MCP server that reads and writes markdown files
inside configured project directories. It runs on your machine with your user
permissions, so local configuration deserves the same care as any other
developer tool that can access your files.
### What Basic Memory Controls
- Filesystem-touching tools validate paths against the configured project root
with `validate_project_path()`, resolved paths, and `Path.is_relative_to()`.
Path traversal attempts such as `../../etc/passwd` are blocked at this layer.
- Scan optimizations in `sync_service.py` call `find` through
`asyncio.create_subprocess_exec()` with explicit argument lists. Project paths
are passed as data, not interpolated into shell strings.
- Auto-update code uses hardcoded commands, list-form arguments, and
`stdin=DEVNULL`. User-controlled strings do not reach a shell there.
### MCP Client-Side Risk
Recent MCP ecosystem research has highlighted a client-side pattern where an
MCP host can be configured to run arbitrary commands as "servers." That risk is
in the host configuration, not in notes or Basic Memory tool input.
The recommended Basic Memory MCP configuration uses a known command with
explicit arguments:
```json
{
"mcpServers": {
"basic-memory": {
"command": "uvx",
"args": ["basic-memory", "mcp"]
}
}
}
```
Only add MCP server entries from sources you trust. Avoid inline shell scripts
or command strings copied from untrusted sources. Treat third-party MCP server
configuration with the same scrutiny as any locally executed program.
Related ecosystem context:
- OX Security: The Mother of All AI Supply Chains
- CSO Online: RCE by design: MCP architectural choice haunts AI agent ecosystem
### Out Of Scope
- Basic Memory does not execute note content as code. Notes are returned as
data to the LLM.
- Basic Memory does not open network ports by default. The MCP server uses
stdio; the optional REST API is intended for localhost use.
- Basic Memory is designed for single-user local knowledge bases and does not
implement access controls between operating-system users.
## Secure Configuration Checklist
- MCP config `command` points to `uvx` or a trusted binary, not a shell string.
- Project paths in Basic Memory config come from trusted local configuration.
- If exposing the REST API, bind it only to localhost.
- Review any third-party MCP servers before adding them to your host config.
If you find a vulnerability, please contact hello@basicmachines.co
+2 -5
View File
@@ -1,8 +1,5 @@
# Docker Compose configuration for Basic Memory with PostgreSQL
# Use this for local development and testing with Postgres backend.
#
# The Postgres backend requires the pgvector extension (semantic search).
# This image bundles pgvector; plain postgres:17 will not work for vector search.
# Use this for local development and testing with Postgres backend
#
# Usage:
# docker-compose -f docker-compose-postgres.yml up -d
@@ -10,7 +7,7 @@
services:
postgres:
image: pgvector/pgvector:pg17
image: postgres:17
container_name: basic-memory-postgres
environment:
# Local development/test credentials - NOT for production
+1 -3
View File
@@ -17,9 +17,7 @@ services:
volumes:
# Persistent storage for configuration and database
# 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
- basic-memory-config:/root/.basic-memory:rw
# Mount your knowledge directory (required)
# Change './knowledge' to your actual Obsidian vault or knowledge directory
+7 -25
View File
@@ -106,7 +106,7 @@ The parser excludes these list item patterns:
|---------|---------|--------|
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
| Markdown links | `- [text](url)` | URL link syntax |
| Bare wiki links | `- [[Target]]` | Treated as a `links_to` relation instead |
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
@@ -116,65 +116,47 @@ Relations connect documents to form the knowledge graph. There are two kinds.
### Explicit Relations
Written as list items with a relation type and a `[[wiki link]]` target. Unquoted
relation types are single tokens. Quote relation types that contain spaces.
Written as list items with a relation type and a `[[wiki link]]` target.
**Syntax:**
```
- relation_type [[Target Entity]] (context)
- "multi word relation type" [[Target Entity]] (context)
- 'multi word relation type' [[Target Entity]] (context)
```
| Part | Required | Description |
|------|----------|-------------|
| `relation_type` | Yes | Single unquoted token before `[[`, or quoted text for multi-word labels. |
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
### Examples
Explicit relations:
```markdown
- implements [[Search Design]]
- depends_on [[Database Schema]]
- works_at [[Y Combinator]] (co-founder)
- "based on" [[Customer Interview]]
- 'in response to' [[Incident Review]]
```
Bare wiki links and prose list items create implicit `links_to` relations:
```markdown
- [[Some Entity]]
- some other thing [[Some Entity]]
```
Both examples above create `links_to [[Some Entity]]`. Use quotes when the words before
`[[` are meant to be a multi-word relation type.
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
Common relation types:
- `implements`, `depends_on`, `relates_to`, `inspired_by`
- `extends`, `part_of`, `contains`, `pairs_with`
- `works_at`, `authored`, `collaborated_with`
Any single-token text or quoted text works as a relation type. These are conventions,
not a fixed set.
Any text works as a relation type. These are conventions, not a fixed set.
### Inline References
Wiki links appearing in regular prose create implicit `links_to` relations. This includes
list items that do not match the explicit relation grammar above.
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
```markdown
This builds on [[Core Design]] and uses [[Utility Functions]].
- We should revisit [[Search Design]] after the API changes.
```
This creates three relations: `links_to [[Core Design]]`, `links_to [[Utility Functions]]`,
and `links_to [[Search Design]]`.
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
### Forward References
+1 -41
View File
@@ -91,11 +91,10 @@ SQLite Database (Index)
# List all projects
projects = await list_memory_projects()
# Response structure (each entry includes external_id you can pass as project_id):
# Response structure:
# [
# {
# "name": "main",
# "external_id": "550e8400-e29b-41d4-a716-446655440000",
# "path": "/Users/name/notes",
# "is_default": True,
# "note_count": 156,
@@ -103,7 +102,6 @@ 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,
@@ -166,44 +164,6 @@ 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:**
+24 -95
View File
@@ -94,18 +94,13 @@ bm cloud setup
```
**What this does:**
1. Installs rclone with a supported package manager (if needed)
1. Installs rclone automatically (if needed)
2. Fetches your tenant information from cloud
3. Generates scoped S3 credentials for sync
4. Configures single rclone remote: `basic-memory-cloud`
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
install scripts with `sudo`; if no supported package manager is found, the CLI prints
manual install instructions.
### 3. Add Projects with Sync
Create projects with optional local sync paths:
@@ -118,7 +113,7 @@ bm project add research --cloud
bm project add research --cloud --local-path ~/Documents/research
# Or configure sync for existing project
bm cloud sync-setup research ~/Documents/research
bm project sync-setup research ~/Documents/research
```
**What happens under the covers:**
@@ -241,7 +236,7 @@ bm project add research --cloud --local-path ~/Documents/research
```bash
# Project already exists on cloud
bm cloud sync-setup research ~/Documents/research
bm project sync-setup research ~/Documents/research
```
**What this does:**
@@ -490,9 +485,6 @@ bm cloud create-key "my-laptop" # Creates key and saves it locally
```
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
On POSIX systems, Basic Memory writes `~/.basic-memory/` as user-private (`0700`) and
`config.json` as user-read/write only (`0600`). Treat this config file as a credential
file when an API key is saved.
### Setting Project Modes
@@ -582,62 +574,33 @@ bm cloud logout
**Default patterns:**
```gitignore
# Hidden files and directories
.*
# Basic Memory internals
*.db
*.db-shm
*.db-wal
config.json
# Version control
.git
.svn
.git/**
# Python
__pycache__
__pycache__/**
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
*.egg-info
.tox
.mypy_cache
.ruff_cache
# Virtual environments
.venv
venv
env
.env
.venv/**
venv/**
# Node.js
node_modules
node_modules/**
# Build artifacts
build
dist
.cache
# IDE
.idea
.vscode
# Basic Memory internals
memory.db/**
memory.db-shm/**
memory.db-wal/**
config.json/**
watch-status.json/**
.bmignore.rclone/**
# OS files
.DS_Store
Thumbs.db
desktop.ini
.DS_Store/**
Thumbs.db/**
# Obsidian
.obsidian
# Temporary files
*.tmp
*.swp
*.swo
*~
# Environment files
.env/**
.env.local/**
```
**How it works:**
@@ -646,11 +609,6 @@ desktop.ini
3. Rclone uses filters during sync
4. Same patterns used by all projects
During conversion, file patterns exclude the direct match and recursive contents.
For example, `config.json` becomes both `- config.json` and `- config.json/**`,
while `.*` becomes both `- .*` and `- .*/**`. Directory-only patterns keep
their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
**Customizing:**
```bash
@@ -658,7 +616,7 @@ their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
code ~/.basic-memory/.bmignore
# Add custom patterns
echo "*.tmp" >> ~/.basic-memory/.bmignore
echo "*.tmp/**" >> ~/.basic-memory/.bmignore
# Next sync uses updated patterns
bm project bisync --name research
@@ -666,33 +624,6 @@ bm project bisync --name research
## Troubleshooting
### Rclone Setup Cannot Install Automatically
**Problem:** `bm cloud setup` cannot find a supported package manager, or package-manager
installation fails.
**Explanation:** The CLI avoids remote privileged install scripts. It only invokes known
package managers and otherwise asks you to install rclone manually.
**Solution:** Install rclone with your OS package manager, then rerun setup:
```bash
# macOS
brew install rclone
# Debian/Ubuntu
sudo apt install rclone
# Fedora
sudo dnf install rclone
# Arch
sudo pacman -S rclone
# After rclone is on PATH
bm cloud setup
```
### Authentication Issues
**Problem:** "Authentication failed" or "Invalid token"
@@ -808,7 +739,7 @@ bm project sync --name research
**Solution:**
```bash
bm cloud sync-setup research ~/Documents/research
bm project sync-setup research ~/Documents/research
bm project bisync --name research --resync
```
@@ -828,10 +759,8 @@ If instance is down, wait a few minutes and retry.
- **Authentication**: OAuth 2.1 with PKCE flow
- **Tokens**: Stored securely in `~/.basic-memory/basic-memory-cloud.json`
- **API keys**: Stored in `~/.basic-memory/config.json`, which is written with private file permissions on POSIX systems
- **Transport**: All data encrypted in transit (HTTPS)
- **Credentials**: Scoped S3 credentials (read-write to your tenant only)
- **Rclone setup**: Uses package managers or manual instructions; no remote privileged install-script fallback
- **Isolation**: Your data isolated from other tenants
- **Ignore patterns**: Sensitive files automatically excluded via `.bmignore`
@@ -856,7 +785,7 @@ bm cloud create-key <name> # Create API key via cloud API (requires OAuth login
### Setup
```bash
bm cloud setup # Install rclone via package manager and configure credentials
bm cloud setup # Install rclone and configure credentials
```
### Project Management
@@ -866,7 +795,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 cloud sync-setup <name> <path> # Add sync to existing project
bm project sync-setup <name> <path> # Add sync to existing project
bm project rm <name> # Delete project
```
-1
View File
@@ -263,7 +263,6 @@ The sqlite-vec extension is loaded per-connection. Vector tables are created laz
### Postgres (cloud)
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
+3 -3
View File
@@ -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 {{args}}
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon --testmon-forceselect {{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 via pytest-testmon
# Fast local loop: lint, format, typecheck, impacted tests
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
+6 -12
View File
@@ -25,16 +25,15 @@ dependencies = [
"unidecode>=1.3.8",
"dateparser>=1.2.0",
"watchfiles>=1.0.4",
"fastapi[standard]>=0.136.1",
"fastapi[standard]>=0.115.8",
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
# Keep FastMCP pinned until each minor upgrade passes the MCP transport matrix.
"fastmcp==3.3.1",
"fastmcp>=3.0.1,<4",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
"aiofiles>=24.1.0",
"aiofiles>=24.1.0", # Optional observability (disabled by default via config)
"asyncpg>=0.30.0",
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
"pytest-asyncio>=1.2.0",
@@ -48,8 +47,6 @@ dependencies = [
"fastembed>=0.7.4",
"sqlite-vec>=0.1.6",
"openai>=1.100.2",
"logfire>=4.19.0",
"psutil>=5.9.0",
]
[project.urls]
@@ -61,6 +58,9 @@ 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,12 +71,6 @@ 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
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.21.5",
"version": "0.20.3",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.21.5",
"version": "0.20.3",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.21.5"
__version__ = "0.20.3"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -1,86 +0,0 @@
"""Remove orphaned search rows whose project was already deleted.
Revision ID: n7i8j9k0l1m2
Revises: m6h7i8j9k0l1
Create Date: 2026-05-15 18:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
revision: str = "n7i8j9k0l1m2"
down_revision: Union[str, None] = "m6h7i8j9k0l1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(connection, table_name: str) -> bool:
"""Inspector-based table check, dialect agnostic.
Trigger: SQLite creates search_index as an FTS5 virtual table at runtime
via SearchRepository.init_search_index, not through Alembic, so fresh
installs hit this migration before the table exists.
Why: a blind DELETE against a missing table fails the whole upgrade.
Outcome: callers skip the sweep when the table isn't present yet — the
runtime-created table on a fresh DB has no orphans to clean.
"""
return table_name in inspect(connection).get_table_names()
def upgrade() -> None:
"""Purge orphaned search rows left over from prior project deletions.
Trigger: project deletion on SQLite never removed the derived FTS rows,
because the FTS5 virtual table can't carry a foreign key. The leak shows
up in two shapes:
1. project_id no longer exists in `project` (deleted project, id never
reused).
2. project_id still exists but `entity_id` no longer exists in `entity`
auto-increment handed the id to a brand-new project and the FTS
rows from the deleted predecessor masquerade as the new tenant's data.
Why: search_index.project_id is the only scope predicate the search
repository applies, so leftover rows surface under the wrong project on
every search.
Outcome: a one-time sweep deletes both shapes, from the FTS index and
from search_vector_chunks. Postgres already cascaded on FK delete, so
these statements are no-ops there.
"""
connection = op.get_bind()
if _table_exists(connection, "search_index"):
op.execute(
"""
DELETE FROM search_index
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_index
WHERE entity_id IS NOT NULL
AND entity_id NOT IN (SELECT id FROM entity)
"""
)
if _table_exists(connection, "search_vector_chunks"):
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE project_id NOT IN (SELECT id FROM project)
"""
)
op.execute(
"""
DELETE FROM search_vector_chunks
WHERE entity_id NOT IN (SELECT id FROM entity)
"""
)
def downgrade() -> None:
"""No-op: orphan rows cannot be reconstructed."""
pass
+4 -40
View File
@@ -4,7 +4,6 @@ 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
@@ -26,16 +25,10 @@ from basic_memory.api.v2.routers.project_router import (
list_projects,
synchronize_projects,
)
import logfire
from basic_memory import telemetry
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
@@ -51,7 +44,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
set_container(container)
app.state.container = container
with logfire.span(
with telemetry.operation(
"api.lifecycle.startup",
entrypoint="api",
mode=container.mode.name.lower(),
@@ -76,7 +69,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
yield
# Shutdown - coordinator handles clean task cancellation
with logfire.span(
with telemetry.operation(
"api.lifecycle.shutdown",
entrypoint="api",
mode=container.mode.name.lower(),
@@ -94,32 +87,6 @@ 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}")
@@ -179,7 +146,4 @@ 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="Internal server error"),
)
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
@@ -10,7 +10,6 @@ import logging
from fastapi import APIRouter, Form, HTTPException, UploadFile, status, Path
from basic_memory.deps import (
AppConfigDep,
ChatGPTImporterV2ExternalDep,
ClaudeConversationsImporterV2ExternalDep,
ClaudeProjectsImporterV2ExternalDep,
@@ -28,21 +27,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/import", tags=["import-v2"])
async def read_import_upload(file: UploadFile, max_bytes: int) -> bytes:
"""Read an import upload with a hard cap before JSON parsing."""
content = await file.read(max_bytes + 1)
if len(content) > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=f"Import file exceeds maximum size of {max_bytes} bytes.",
)
return content
@router.post("/chatgpt", response_model=ChatImportResult)
async def import_chatgpt(
importer: ChatGPTImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -62,13 +49,12 @@ async def import_chatgpt(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/claude/conversations", response_model=ChatImportResult)
async def import_claude_conversations(
importer: ClaudeConversationsImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -88,13 +74,12 @@ async def import_claude_conversations(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude conversations for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/claude/projects", response_model=ProjectImportResult)
async def import_claude_projects(
importer: ClaudeProjectsImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("projects"),
@@ -114,13 +99,12 @@ async def import_claude_projects(
HTTPException: If import fails.
"""
logger.info(f"V2 Importing Claude projects for project {project_id}")
return await import_file(importer, file, directory, config.import_upload_max_bytes)
return await import_file(importer, file, directory)
@router.post("/memory-json", response_model=EntityImportResult)
async def import_memory_json(
importer: MemoryJsonImporterV2ExternalDep,
config: AppConfigDep,
file: UploadFile,
project_id: str = Path(..., description="Project external UUID"),
directory: str = Form("conversations"),
@@ -142,7 +126,7 @@ async def import_memory_json(
logger.info(f"V2 Importing memory.json for project {project_id}")
try:
file_data = []
file_bytes = await read_import_upload(file, config.import_upload_max_bytes)
file_bytes = await file.read()
file_str = file_bytes.decode("utf-8")
for line in file_str.splitlines():
json_data = json.loads(line)
@@ -154,8 +138,6 @@ async def import_memory_json(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.error_message or "Import failed",
)
except HTTPException:
raise
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
@@ -165,16 +147,13 @@ async def import_memory_json(
return result
async def import_file(
importer: Importer, file: UploadFile, destination_directory: str, max_bytes: int
):
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
"""Helper function to import a file using an importer instance.
Args:
importer: The importer instance to use
file: The file to import
destination_directory: Destination directory for imported content
max_bytes: Maximum upload size in bytes; raises HTTP 413 if exceeded
Returns:
Import result from the importer
@@ -184,8 +163,7 @@ async def import_file(
"""
try:
# Process file
upload_bytes = await read_import_upload(file, max_bytes)
json_data = json.loads(upload_bytes)
json_data = json.load(file.file)
result = await importer.import_data(json_data, destination_directory)
if not result.success: # pragma: no cover
raise HTTPException(
@@ -195,8 +173,6 @@ async def import_file(
return result
except HTTPException:
raise
except Exception as e:
logger.exception("V2 Import failed")
raise HTTPException(
@@ -10,10 +10,10 @@ Key improvements:
- Simplified caching strategies
"""
from fastapi import APIRouter, HTTPException, Response, Path
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path, Query
from loguru import logger
import logfire
from basic_memory import telemetry
from basic_memory.deps import (
EntityServiceV2ExternalDep,
SearchServiceV2ExternalDep,
@@ -24,6 +24,7 @@ from basic_memory.deps import (
RelationRepositoryV2ExternalDep,
ProjectExternalIdPathDep,
TaskSchedulerDep,
FileServiceV2ExternalDep,
)
from basic_memory.schemas import DeleteEntitiesResponse
from basic_memory.schemas.base import Entity
@@ -38,7 +39,6 @@ from basic_memory.schemas.v2 import (
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
OrphanEntitiesResponse,
)
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
@@ -75,72 +75,34 @@ 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).
"""
with logfire.span(
"api.request.knowledge.get_graph",
entrypoint="api",
domain="knowledge",
action="get_graph",
):
logger.info("API v2 request: 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)
## 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))
logger.info(f"API v2 response: graph with {len(nodes)} nodes and {len(edges)} edges")
return GraphResponse(nodes=nodes, edges=edges)
## Resolution endpoint
@@ -181,7 +143,7 @@ async def resolve_identifier(
"resolution_method": "permalink"
}
"""
with logfire.span(
with telemetry.operation(
"api.request.knowledge.resolve_entity",
entrypoint="api",
domain="knowledge",
@@ -189,13 +151,25 @@ async def resolve_identifier(
):
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
entity = await entity_repository.get_by_external_id(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)
resolution_method = "external_id" if entity else "search"
if not entity:
entity = await link_resolver.resolve_link(
data.identifier, source_path=data.source_path, strict=data.strict
)
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
)
if entity:
if entity.permalink == data.identifier:
resolution_method = "permalink"
@@ -209,14 +183,20 @@ async def resolve_identifier(
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
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,
)
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,
)
logger.debug(
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
@@ -248,7 +228,7 @@ async def get_entity_by_id(
Raises:
HTTPException: 404 if entity not found
"""
with logfire.span(
with telemetry.operation(
"api.request.knowledge.get_entity",
entrypoint="api",
domain="knowledge",
@@ -256,13 +236,25 @@ async def get_entity_by_id(
):
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
entity = await entity_repository.get_by_external_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)
if not entity:
raise HTTPException(
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
)
result = EntityResponseV2.model_validate(entity)
with telemetry.scope(
"api.knowledge.get_entity.shape_response",
domain="knowledge",
action="get_entity",
phase="shape_response",
):
result = EntityResponseV2.model_validate(entity)
logger.info(f"API v2 response: external_id={entity_id}, title='{result.title}'")
return result
@@ -275,44 +267,106 @@ 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 logfire.span(
with telemetry.operation(
"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
)
# 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,
)
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,
)
result = EntityResponseV2.model_validate(entity)
# The write service already returns the canonical markdown accepted for this request.
result = result.model_copy(update={"content": write_result.content})
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})
logger.info(
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
@@ -327,13 +381,18 @@ 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.
@@ -342,52 +401,121 @@ 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 logfire.span(
with telemetry.operation(
"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}")
existing = await entity_repository.get_by_external_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)
created = existing is None
if existing:
write_result = await entity_service.update_entity_with_content(existing, data)
entity = write_result.entity
response.status_code = 200
else:
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
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
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,
)
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,
)
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,
)
result = EntityResponseV2.model_validate(entity)
result = result.model_copy(update={"content": write_result.content})
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})
logger.info(
f"API v2 response: external_id={entity_id}, created={created}, status_code={response.status_code}"
@@ -398,19 +526,25 @@ 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
@@ -418,43 +552,115 @@ async def edit_entity_by_id(
Raises:
HTTPException: 404 if entity not found, 400 if edit fails
"""
with logfire.span(
with telemetry.operation(
"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}'"
)
entity = await entity_repository.get_by_external_id(entity_id)
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)
if not entity: # pragma: no cover
raise HTTPException(
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
)
try:
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,
)
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,
)
result = EntityResponseV2.model_validate(updated_entity)
result = result.model_copy(update={"content": write_result.content})
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})
logger.info(
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
@@ -472,10 +678,12 @@ 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.
@@ -487,25 +695,23 @@ async def delete_entity_by_id(
Note: Returns deleted=False if entity doesn't exist (idempotent)
"""
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}")
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)
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
# Remove from search index if search service available
if search_service:
background_tasks.add_task(search_service.handle_delete, entity) # pragma: no cover
return DeleteEntitiesResponse(deleted=deleted)
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
return DeleteEntitiesResponse(deleted=deleted)
## Move endpoint
@@ -514,6 +720,7 @@ 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,
@@ -536,58 +743,48 @@ async def move_entity(
Returns:
Updated entity with new file path
"""
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}'"
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,
)
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,
# 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,
)
# 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,
)
result = EntityResponseV2.model_validate(moved_entity)
result = EntityResponseV2.model_validate(moved_entity)
logger.info(f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'")
logger.info(
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
)
return result
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))
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
@@ -596,6 +793,7 @@ 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,
@@ -616,46 +814,40 @@ async def move_directory(
Returns:
DirectoryMoveResult with counts and details of moved files
"""
with logfire.span(
"api.request.knowledge.move_directory",
entrypoint="api",
domain="knowledge",
action="move_directory",
):
logger.info(
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
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,
)
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,
)
# 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
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))
except Exception as e:
logger.error(f"Error moving directory: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Delete directory endpoint
@@ -680,26 +872,20 @@ async def delete_directory(
Returns:
DirectoryDeleteResult with counts and details of deleted files
"""
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}'")
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
import logfire
from basic_memory import telemetry
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 logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"api.memory.build_context.shape_response",
domain="memory",
action="build_context",
@@ -11,7 +11,7 @@ Key improvements:
"""
import os
from typing import Literal, Optional
from typing import Optional
from fastapi import APIRouter, HTTPException, Body, Query, Path
from loguru import logger
@@ -25,8 +25,6 @@ from basic_memory.deps import (
ProjectExternalIdPathDep,
)
from basic_memory.schemas import SyncReportResponse
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas.project_info import (
ProjectItem,
ProjectList,
@@ -38,71 +36,6 @@ from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveRespons
from basic_memory.utils import normalize_project_path, generate_permalink
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
ProjectResolveMethod = Literal["external_id", "name", "permalink"]
def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]:
"""Split ``<workspace>/<project>`` identifiers while preserving plain project names."""
cleaned = identifier.strip()
if "/" not in cleaned:
return None, cleaned
workspace_identifier, project_identifier = cleaned.split("/", 1)
if not workspace_identifier or not project_identifier:
return None, cleaned
return workspace_identifier, project_identifier
async def _resolve_project_identifier_candidate(
project_repository: ProjectRepository,
identifier: str,
) -> tuple[Project | None, ProjectResolveMethod]:
"""Resolve one project identifier candidate and report the matching method."""
identifier_permalink = generate_permalink(identifier)
project = await project_repository.get_by_external_id(identifier)
if project:
return project, "external_id"
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
return project, "permalink"
project = await project_repository.get_by_name_case_insensitive(identifier)
if project:
return project, "name" # pragma: no cover
return None, "name"
async def _resolve_project_identifier(
project_repository: ProjectRepository,
identifier: str,
) -> tuple[Project | None, ProjectResolveMethod]:
"""Resolve exact identifiers first, then accepted workspace-qualified forms."""
project, resolution_method = await _resolve_project_identifier_candidate(
project_repository,
identifier,
)
if project:
return project, resolution_method
workspace_identifier, project_identifier = _split_qualified_project_identifier(identifier)
if workspace_identifier is None:
return None, resolution_method
# Trigger: an MCP disambiguation error suggested ``workspace/project``.
# Why: request routing already selected the workspace/tenant; this endpoint
# only needs the project segment to validate the active project.
# Outcome: models can follow the hint verbatim instead of looping on a 404.
project, resolution_method = await _resolve_project_identifier_candidate(
project_repository,
project_identifier,
)
if project:
return project, resolution_method
return None, resolution_method
@router.get("/", response_model=ProjectList)
@@ -314,10 +247,28 @@ async def resolve_project_identifier(
"""
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
project, resolution_method = await _resolve_project_identifier(
project_repository,
data.identifier,
)
# Generate permalink for comparison
identifier_permalink = generate_permalink(data.identifier)
resolution_method = "name"
project = None
# Try external_id first (UUID format)
project = await project_repository.get_by_external_id(data.identifier)
if project:
resolution_method = "external_id"
# If not found by external_id, try by permalink (exact match)
if not project:
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
resolution_method = "permalink"
# If not found by permalink, try case-insensitive name search
if not project:
project = await project_repository.get_by_name_case_insensitive(data.identifier)
if project:
resolution_method = "name" # pragma: no cover
if not project:
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
@@ -15,7 +15,7 @@ from pathlib import Path as PathLib
from fastapi import APIRouter, HTTPException, Response, Path
from loguru import logger
import logfire
from basic_memory import telemetry
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 logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"api.resource.create.upsert_entity",
domain="resource",
action="create",
@@ -205,7 +205,7 @@ async def create_resource(
):
entity = await entity_repository.add(entity)
with logfire.span(
with telemetry.scope(
"api.resource.create.search_index",
domain="resource",
action="create",
@@ -258,7 +258,7 @@ async def update_resource(
Raises:
HTTPException: 404 if entity not found, 400 for invalid paths
"""
with logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"api.resource.update.update_entity",
domain="resource",
action="update",
@@ -329,7 +329,7 @@ async def update_resource(
if updated_entity is None:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
with logfire.span(
with telemetry.scope(
"api.resource.update.search_index",
domain="resource",
action="update",
@@ -4,17 +4,15 @@ This router uses external_id UUIDs for stable, API-friendly routing.
V1 uses string-based project names which are less efficient and less stable.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Path
import logfire
from basic_memory import telemetry
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, SearchRetrievalMode
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.deps import (
SearchServiceV2ExternalDep,
EntityServiceV2ExternalDep,
@@ -50,7 +48,7 @@ async def search(
Returns:
SearchResponse with paginated search results
"""
with logfire.span(
with telemetry.operation(
"api.request.search",
entrypoint="api",
domain="search",
@@ -67,9 +65,9 @@ async def search(
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
):
offset = (page - 1) * page_size
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
fetch_limit = page_size + 1
try:
with logfire.span(
with telemetry.scope(
"api.search.search.execute_query",
domain="search",
action="search",
@@ -77,14 +75,7 @@ async def search(
page=page,
page_size=page_size,
):
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
results = await search_service.search(query, limit=fetch_limit, offset=offset)
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
@@ -92,24 +83,18 @@ async def search(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
with logfire.span(
with telemetry.scope(
"api.search.search.paginate_results",
domain="search",
action="search",
phase="paginate_results",
result_count=len(results),
):
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]
has_more = len(results) > page_size
if has_more:
results = results[:page_size]
with logfire.span(
with telemetry.scope(
"api.search.search.hydrate_results",
domain="search",
action="search",
@@ -117,7 +102,7 @@ async def search(
result_count=len(results),
):
search_results = await to_search_results(entity_service, results)
with logfire.span(
with telemetry.scope(
"api.search.search.build_response",
domain="search",
action="search",
@@ -128,7 +113,6 @@ async def search(
results=search_results,
current_page=page,
page_size=page_size,
total=total,
has_more=has_more,
)
+10 -12
View File
@@ -1,6 +1,6 @@
from typing import Any, Protocol, Optional, List, Sequence
import logfire
from basic_memory import telemetry
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import (
EntitySummary,
@@ -18,7 +18,7 @@ from basic_memory.services.context_service import (
class EntityBatchLookup(Protocol):
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Any]: ...
async def find_by_ids(self, ids: List[int]) -> Sequence[Any]: ...
class EntityServiceBatchLookup(Protocol):
@@ -43,7 +43,7 @@ async def to_graph_context(
page: Optional[int] = None,
page_size: Optional[int] = None,
) -> GraphContext:
with logfire.span(
with telemetry.scope(
"memory.hydrate_context",
domain="memory",
action="build_context",
@@ -76,20 +76,18 @@ async def to_graph_context(
if item.to_id:
entity_ids_needed.add(item.to_id)
# Batch fetch just the entity fields needed to shape the response.
# Batch fetch all entities at once - get both title and external_id
entity_title_lookup: dict[int, str] = {}
entity_external_id_lookup: dict[int, str] = {}
if entity_ids_needed:
with logfire.span(
with telemetry.scope(
"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_for_hydration(
list(entity_ids_needed)
)
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
for e in entities:
entity_title_lookup[e.id] = e.title
entity_external_id_lookup[e.id] = e.external_id
@@ -150,7 +148,7 @@ async def to_graph_context(
created_at=item.created_at,
)
with logfire.span(
with telemetry.scope(
"memory.hydrate_context.shape_results",
domain="memory",
action="build_context",
@@ -200,7 +198,7 @@ async def to_graph_context(
async def to_search_results(
entity_service: EntityServiceBatchLookup, results: List[SearchIndexRow]
) -> list[SearchResult]:
with logfire.span(
with telemetry.scope(
"search.hydrate_results",
domain="search",
action="search",
@@ -217,7 +215,7 @@ async def to_search_results(
# Single batch fetch for all entities
entities_by_id: dict[int, Any] = {}
with logfire.span(
with telemetry.scope(
"search.hydrate_results.fetch_entities",
domain="search",
action="search",
@@ -229,7 +227,7 @@ async def to_search_results(
entities_by_id = {e.id: e for e in entities}
search_results = []
with logfire.span(
with telemetry.scope(
"search.hydrate_results.shape_results",
domain="search",
action="search",
+2 -2
View File
@@ -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
import logfire # noqa: E402
from basic_memory import telemetry # 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(
logfire.span(
telemetry.operation(
f"cli.command.{command_name}",
entrypoint="cli",
command_name=command_name,
+1 -2
View File
@@ -1,6 +1,6 @@
"""CLI commands for basic-memory."""
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
from . import (
import_claude_projects,
import_chatgpt,
@@ -18,7 +18,6 @@ __all__ = [
"import_memory_json",
"mcp",
"import_claude_conversations",
"orphans",
"import_claude_projects",
"import_chatgpt",
"tool",
@@ -14,24 +14,6 @@ class BisyncError(Exception):
pass
def _rclone_exclude_filters(pattern: str) -> list[str]:
"""Return rclone exclude filters for a gitignore-style pattern."""
if pattern.endswith("/"):
# Trigger: gitignore-style patterns ending in / are directory-only rules.
# Why: stripping the slash would also exclude a same-named file.
# Outcome: rclone keeps the directory rule and excludes recursive contents.
return [f"- {pattern}", f"- {pattern}**"]
path_pattern = pattern.removesuffix("/**")
# Trigger: rclone treats a directory contents filter separately from the
# directory/file path itself.
# Why: files like config.json and directory markers like .obsidian must both
# be excluded, along with anything below matching directories.
# Outcome: every ignore pattern excludes the direct match and recursive children.
return [f"- {path_pattern}", f"- {path_pattern}/**"]
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
@@ -96,11 +78,19 @@ def convert_bmignore_to_rclone_filters() -> Path:
patterns.append(line)
continue
patterns.extend(_rclone_exclude_filters(line))
# Convert gitignore pattern to rclone filter syntax
# gitignore: node_modules → rclone: - node_modules/**
# gitignore: *.pyc → rclone: - *.pyc
if "*" in line:
# Pattern already has wildcard, just add exclude prefix
patterns.append(f"- {line}")
else:
# Directory pattern - add /** for recursive exclude
patterns.append(f"- {line}/**")
except Exception:
# If we can't read the file, create a minimal filter
patterns = ["# Error reading .bmignore, using minimal filters", "- .git", "- .git/**"]
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
# Write rclone filter file
rclone_filter_path.write_text("\n".join(patterns) + "\n")
@@ -76,22 +76,10 @@ def login():
@cloud_app.command()
def logout():
"""Remove stored OAuth tokens and clear cached workspace selection."""
config_manager = ConfigManager()
config = config_manager.config
"""Remove stored OAuth tokens."""
config = ConfigManager().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]")
@@ -179,7 +167,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 cloud sync-setup research ~/Documents/research")
console.print(" bm project 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,7 +20,6 @@ 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()
@@ -139,16 +138,13 @@ 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 resolve_data_dir() / "bisync-state" / project_name
return Path.home() / ".basic-memory" / "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 Any, Optional, cast
from typing import Optional
from rich.console import Console
@@ -53,9 +53,7 @@ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedP
def install_rclone_macos() -> None:
"""Install rclone on macOS using package managers."""
install_errors: list[str] = []
"""Install rclone on macOS using Homebrew or official script."""
# Try Homebrew first
if shutil.which("brew"):
try:
@@ -63,37 +61,35 @@ def install_rclone_macos() -> None:
run_command(["brew", "install", "rclone"])
console.print("[green]rclone installed via Homebrew[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"Homebrew failed: {exc}")
console.print("[yellow]Homebrew installation failed, trying MacPorts...[/yellow]")
except RcloneInstallError:
console.print(
"[yellow]Homebrew installation failed, trying official script...[/yellow]"
)
if shutil.which("port"):
try:
console.print("[blue]Installing rclone via MacPorts...[/blue]")
run_command(["sudo", "port", "install", "rclone"])
console.print("[green]rclone installed via MacPorts[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"MacPorts failed: {exc}")
console.print("[yellow]MacPorts installation failed[/yellow]")
details = "\n".join(f"- {error}" for error in install_errors)
if details:
details = f"\n\nAttempts:\n{details}"
raise RcloneInstallError(
"Could not install rclone automatically with an available package manager.\n\n"
"Install rclone manually with one of:\n"
" brew install rclone\n"
" sudo port install rclone\n"
" Download from https://rclone.org/downloads/ and add rclone to PATH"
f"{details}"
)
# Fallback to official script
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: brew install rclone"
)
def install_rclone_linux() -> None:
"""Install rclone on Linux using package managers."""
install_errors: list[str] = []
"""Install rclone on Linux using package managers or official script."""
# Try snap first (most universal)
if shutil.which("snap"):
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError:
console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
# Try apt (Debian/Ubuntu)
if shutil.which("apt"):
try:
console.print("[blue]Installing rclone via apt...[/blue]")
@@ -101,75 +97,18 @@ def install_rclone_linux() -> None:
run_command(["sudo", "apt", "install", "-y", "rclone"])
console.print("[green]rclone installed via apt[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"apt failed: {exc}")
console.print("[yellow]apt installation failed, trying dnf...[/yellow]")
except RcloneInstallError:
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
if shutil.which("dnf"):
try:
console.print("[blue]Installing rclone via dnf...[/blue]")
run_command(["sudo", "dnf", "install", "-y", "rclone"])
console.print("[green]rclone installed via dnf[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"dnf failed: {exc}")
console.print("[yellow]dnf installation failed, trying yum...[/yellow]")
if shutil.which("yum"):
try:
console.print("[blue]Installing rclone via yum...[/blue]")
run_command(["sudo", "yum", "install", "-y", "rclone"])
console.print("[green]rclone installed via yum[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"yum failed: {exc}")
console.print("[yellow]yum installation failed, trying pacman...[/yellow]")
if shutil.which("pacman"):
try:
console.print("[blue]Installing rclone via pacman...[/blue]")
run_command(["sudo", "pacman", "-S", "--noconfirm", "rclone"])
console.print("[green]rclone installed via pacman[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"pacman failed: {exc}")
console.print("[yellow]pacman installation failed, trying zypper...[/yellow]")
if shutil.which("zypper"):
try:
console.print("[blue]Installing rclone via zypper...[/blue]")
run_command(["sudo", "zypper", "--non-interactive", "install", "rclone"])
console.print("[green]rclone installed via zypper[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"zypper failed: {exc}")
console.print("[yellow]zypper installation failed, trying snap...[/yellow]")
if shutil.which("snap"):
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError as exc:
install_errors.append(f"snap failed: {exc}")
console.print("[yellow]snap installation failed[/yellow]")
details = "\n".join(f"- {error}" for error in install_errors)
if details:
details = f"\n\nAttempts:\n{details}"
raise RcloneInstallError(
"Could not install rclone automatically with an available package manager.\n\n"
"Install rclone manually with one of your OS package managers, for example:\n"
" sudo apt install rclone\n"
" sudo dnf install rclone\n"
" sudo yum install rclone\n"
" sudo pacman -S rclone\n"
" sudo zypper install rclone\n"
" sudo snap install rclone\n"
"Or download from https://rclone.org/downloads/ and add rclone to PATH"
f"{details}"
)
# Fallback to official script
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: sudo snap install rclone"
)
def install_rclone_windows() -> None:
@@ -270,38 +209,27 @@ def refresh_windows_path() -> None:
if platform.system().lower() != "windows":
return
# Importing here after performing platform detection. Non-Windows type checkers may still
# resolve a stub without registry members, so keep this platform-only module dynamic here.
# Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
# warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
import winreg
winreg_module = cast(Any, winreg)
user_key_path = r"Environment"
system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
new_path = ""
# Read user PATH
try:
reg_key = winreg_module.OpenKey(
winreg_module.HKEY_CURRENT_USER,
user_key_path,
0,
winreg_module.KEY_READ,
)
user_path, _ = winreg_module.QueryValueEx(reg_key, "PATH")
winreg_module.CloseKey(reg_key)
reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
user_path = ""
# Read system PATH
try:
reg_key = winreg_module.OpenKey(
winreg_module.HKEY_LOCAL_MACHINE,
system_key_path,
0,
winreg_module.KEY_READ,
)
system_path, _ = winreg_module.QueryValueEx(reg_key, "PATH")
winreg_module.CloseKey(reg_key)
reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
system_path = ""
@@ -6,11 +6,10 @@ from rich.table import Table
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import (
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_identifier,
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
console = Console()
@@ -63,10 +62,7 @@ def list_workspaces() -> None:
@workspace_app.command("set-default")
def set_default_workspace(
identifier: str = typer.Argument(
...,
help="Workspace name, slug, type, or tenant_id to set as default",
),
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
) -> None:
"""Set the default cloud workspace.
@@ -75,7 +71,6 @@ def set_default_workspace(
Examples:
bm cloud workspace set-default Personal
bm cloud workspace set-default organization
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
"""
@@ -92,19 +87,19 @@ def set_default_workspace(
console.print("[yellow]No accessible workspaces found.[/yellow]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, identifier)]
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
if not matches:
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(f"[red]Error: Workspace '{identifier}' matches multiple workspaces.[/red]")
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
selected = matches[0]
+1 -118
View File
@@ -1,10 +1,8 @@
"""Database management commands."""
import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from pathlib import Path
import psutil
import typer
from loguru import logger
from rich.console import Console
@@ -23,103 +21,6 @@ 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."""
@@ -185,16 +86,6 @@ 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(
@@ -203,14 +94,6 @@ 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
+1 -1
View File
@@ -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())
api_result = await knowledge_client.create_entity(api_note.model_dump(), fast=False)
api_file = project_path / api_result.file_path
if not api_file.exists():
-93
View File
@@ -1,93 +0,0 @@
"""Orphans command - show entities with no relations in the knowledge graph."""
import json
from typing import Annotated, Optional
import typer
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients.knowledge import KnowledgeClient
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas.v2.graph import GraphNode
console = Console()
async def run_orphans(project: Optional[str] = None) -> tuple[str, list[GraphNode]]:
"""Fetch entities that have no relations in the knowledge graph."""
project = project or ConfigManager().default_project
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
entities = await KnowledgeClient(client, project_item.external_id).get_orphans()
return project_item.name, entities
@app.command()
def orphans(
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Show entities that have no relations in the knowledge graph.
Orphan entities have no incoming or outgoing connections. These may indicate
newly created notes not yet linked to other entities, or notes that have had
their relations removed.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
project_name, entities = run_with_cleanup(run_orphans(project))
if json_output:
print(json.dumps([entity.model_dump(mode="json") for entity in entities], indent=2))
return
if not entities:
console.print(f"[green]No orphan entities in project '{project_name}'[/green]")
return
table = Table(title=f"{project_name}: Entities Without Relations ({len(entities)} total)")
table.add_column("Title", style="cyan")
table.add_column("File Path", style="yellow")
table.add_column("Type", style="green")
for entity in entities:
table.add_row(
entity.title,
entity.file_path,
entity.note_type or "",
)
console.print(table)
except (ValueError, ToolError) as exc:
if json_output:
print(json.dumps({"error": str(exc)}, indent=2))
else:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(code=1)
except typer.Exit:
raise
except Exception as exc:
logger.error(f"Error fetching orphan entities: {exc}")
if json_output:
print(json.dumps({"error": str(exc)}, indent=2))
else:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(code=1) # pragma: no cover
+59 -363
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import cast
import typer
from loguru import logger
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
@@ -27,17 +26,13 @@ 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 BasicMemoryConfig, ConfigManager, ProjectEntry, ProjectMode
from basic_memory.config import 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
@@ -286,25 +281,27 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
"""Resolve a workspace name, slug, type, or tenant_id to a tenant_id."""
"""Resolve a workspace name or tenant_id to a tenant_id."""
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
if workspace is not None:
workspaces = run_with_cleanup(get_available_workspaces())
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, workspace)]
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
if not matches:
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
if workspaces:
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(f"[red]Error: Workspace '{workspace}' matches multiple workspaces.[/red]")
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
return matches[0].tenant_id
@@ -315,9 +312,9 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
workspaces = run_with_cleanup(get_available_workspaces())
if len(workspaces) == 1:
return workspaces[0].tenant_id
except Exception as exc:
except Exception:
# Workspace resolution is optional until a command needs a specific tenant.
logger.debug("Workspace resolution failed: {}", exc)
pass
return None
@@ -326,11 +323,7 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
def list_projects(
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name, slug, type, or tenant_id",
),
workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
) -> None:
"""List Basic Memory projects from local and (when available) cloud."""
@@ -346,90 +339,17 @@ def list_projects(
try:
config = ConfigManager().config
workspace_filter = workspace
workspace_filter_requested = workspace_filter is not None
# Use explicit workspace, fall back to config default
effective_workspace = workspace or config.default_workspace
local_result: ProjectList | None = None
cloud_results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
available_cloud_workspaces: list[WorkspaceInfo] = []
cloud_result: ProjectList | None = None
cloud_error: Exception | None = None
cloud_workspace_error: Exception | None = None
failed_cloud_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
def _fetch_cloud_workspace_results() -> tuple[
list[tuple[WorkspaceInfo | None, ProjectList]],
list[WorkspaceInfo],
Exception | None,
list[tuple[WorkspaceInfo, Exception]],
]:
from basic_memory.mcp.project_context import (
get_available_workspaces,
)
try:
workspaces = run_with_cleanup(get_available_workspaces())
except Exception as exc:
fallback_workspace = workspace_filter or config.default_workspace
return (
[(None, run_with_cleanup(_list_projects(fallback_workspace)))],
[],
exc,
[],
)
selected_workspaces = workspaces
if workspace_filter is not None:
matches = [
ws for ws in workspaces if workspace_matches_identifier(ws, workspace_filter)
]
if not matches:
console.print(f"[red]Error: Workspace '{workspace_filter}' not found[/red]")
if workspaces:
console.print(
f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]"
)
raise typer.Exit(1)
if len(matches) > 1:
console.print(
f"[red]Error: Workspace '{workspace_filter}' matches multiple workspaces.[/red]"
)
console.print(
"[dim]Choose one of these matching workspaces by slug:\n"
f"{format_workspace_selection_choices(matches)}[/dim]"
)
raise typer.Exit(1)
selected_workspaces = matches
if not selected_workspaces:
return [], workspaces, None, []
results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
failed_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
for cloud_workspace in selected_workspaces:
try:
results.append(
(
cloud_workspace,
run_with_cleanup(_list_projects(cloud_workspace.tenant_id)),
)
)
except Exception as exc:
failed_workspaces.append((cloud_workspace, exc))
if not results and failed_workspaces:
raise failed_workspaces[0][1]
return results, workspaces, None, failed_workspaces
if cloud:
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
with force_routing(cloud=True):
(
cloud_results,
available_cloud_workspaces,
cloud_workspace_error,
failed_cloud_workspaces,
) = _fetch_cloud_workspace_results()
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
elif local:
with force_routing(local=True):
local_result = run_with_cleanup(_list_projects())
@@ -442,17 +362,29 @@ def list_projects(
try:
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
with force_routing(cloud=True):
(
cloud_results,
available_cloud_workspaces,
cloud_workspace_error,
failed_cloud_workspaces,
) = _fetch_cloud_workspace_results()
except typer.Exit:
raise
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
except Exception as exc: # pragma: no cover
cloud_error = exc
# Resolve workspace name for cloud projects (best-effort)
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
if cloud_result and effective_workspace:
try:
from basic_memory.mcp.project_context import get_available_workspaces
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
workspaces = run_with_cleanup(get_available_workspaces())
matched = next(
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
None,
)
if matched:
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
except Exception:
pass
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
@@ -463,126 +395,29 @@ def list_projects(
table.add_column("Sync", style="green")
table.add_column("Default", style="magenta")
row_names_by_key: dict[tuple[str | None, str], str] = {}
project_names_by_permalink: dict[str, str] = {}
local_projects_by_permalink: dict[str, ProjectItem] = {}
cloud_projects_by_key: dict[tuple[str | None, str], ProjectItem] = {}
cloud_workspaces_by_key: dict[tuple[str | None, str], WorkspaceInfo | None] = {}
cloud_projects_by_permalink: dict[str, ProjectItem] = {}
if local_result:
for project in local_result.projects:
permalink = generate_permalink(project.name)
project_names_by_permalink[permalink] = project.name
local_projects_by_permalink[permalink] = project
for cloud_workspace, cloud_result in cloud_results:
workspace_key = cloud_workspace.tenant_id if cloud_workspace else None
if cloud_result:
for project in cloud_result.projects:
permalink = generate_permalink(project.name)
row_key = (workspace_key, permalink)
row_names_by_key[row_key] = project.name
cloud_projects_by_key[row_key] = project
cloud_workspaces_by_key[row_key] = cloud_workspace
cloud_permalinks = {permalink for _, permalink in cloud_projects_by_key}
for permalink, project in local_projects_by_permalink.items():
if permalink not in cloud_permalinks:
row_names_by_key[(None, permalink)] = project.name
cloud_keys_by_permalink: dict[str, list[tuple[str | None, str]]] = {}
for row_key in cloud_projects_by_key:
cloud_keys_by_permalink.setdefault(row_key[1], []).append(row_key)
configured_names_by_permalink = {
generate_permalink(project_name): project_name for project_name in config.projects
}
def _workspace_priority(row_key: tuple[str | None, str]) -> tuple[bool, int, str, str]:
"""Prefer the user's default/personal workspace when a project is duplicated."""
workspace = cloud_workspaces_by_key.get(row_key)
if workspace is None:
return (True, 2, "", row_key[0] or "")
workspace_type_rank = 0 if workspace.workspace_type == "personal" else 1
return (
not workspace.is_default,
workspace_type_rank,
workspace.name.casefold(),
row_key[0] or "",
)
def _select_attached_row_key(
permalink: str, entry: ProjectEntry | None
) -> tuple[str | None, str] | None:
"""Choose the single row that owns local config/default/sync state."""
cloud_keys = cloud_keys_by_permalink.get(permalink, [])
if not cloud_keys:
return (None, permalink)
preferred_workspace_ids: list[str] = []
if entry and entry.workspace_id:
preferred_workspace_ids.append(entry.workspace_id)
if config.default_workspace and config.default_workspace not in preferred_workspace_ids:
preferred_workspace_ids.append(config.default_workspace)
default_cloud_workspace = next(
(item for item in available_cloud_workspaces if item.is_default),
None,
)
if (
default_cloud_workspace
and default_cloud_workspace.tenant_id not in preferred_workspace_ids
):
preferred_workspace_ids.append(default_cloud_workspace.tenant_id)
for workspace_id in preferred_workspace_ids:
for row_key in cloud_keys:
if row_key[0] == workspace_id:
return row_key
if workspace_filter_requested and preferred_workspace_ids:
# A filtered list can exclude the workspace that owns local config state.
# In that case, do not attach local/default/sync state to another workspace row.
return None
default_workspace_keys = [
row_key
for row_key in cloud_keys
if (row_workspace := cloud_workspaces_by_key.get(row_key)) is not None
and row_workspace.is_default
]
if len(default_workspace_keys) == 1:
return default_workspace_keys[0]
if len(cloud_keys) == 1:
return cloud_keys[0]
return sorted(cloud_keys, key=_workspace_priority)[0]
attached_row_by_permalink: dict[str, tuple[str | None, str] | None] = {}
for permalink in set(local_projects_by_permalink) | set(configured_names_by_permalink):
configured_name = configured_names_by_permalink.get(permalink)
local_project = local_projects_by_permalink.get(permalink)
entry_name = configured_name or (local_project.name if local_project else None)
entry = config.projects.get(entry_name) if entry_name else None
attached_row_by_permalink[permalink] = _select_attached_row_key(permalink, entry)
project_names_by_permalink[permalink] = project.name
cloud_projects_by_permalink[permalink] = project
# --- Build unified project list ---
project_rows: list[dict] = []
sorted_row_keys = sorted(
row_names_by_key,
key=lambda key: (row_names_by_key[key], key[0] or ""),
)
for row_key in sorted_row_keys:
_, permalink = row_key
project_name = row_names_by_key[row_key]
is_attached_row = attached_row_by_permalink.get(permalink) == row_key
local_project = local_projects_by_permalink.get(permalink) if is_attached_row else None
cloud_project = cloud_projects_by_key.get(row_key)
cloud_workspace = cloud_workspaces_by_key.get(row_key)
configured_name = configured_names_by_permalink.get(permalink)
configured_entry = (
config.projects.get(configured_name)
if configured_name
else config.projects.get(project_name)
)
entry = configured_entry if is_attached_row else None
for permalink in sorted(project_names_by_permalink):
project_name = project_names_by_permalink[permalink]
local_project = local_projects_by_permalink.get(permalink)
cloud_project = cloud_projects_by_permalink.get(permalink)
entry = config.projects.get(project_name)
local_path = ""
if local_project is not None:
@@ -612,13 +447,9 @@ def list_projects(
else:
cli_route = ProjectMode.LOCAL.value
default_permalink = (
generate_permalink(config.default_project) if config.default_project else None
)
is_default = bool(is_attached_row and permalink == default_permalink)
is_default = config.default_project == project_name
sync_supported = cloud_workspace is None or cloud_workspace.workspace_type == "personal"
has_sync = bool(is_attached_row and entry and entry.local_sync_path and sync_supported)
has_sync = bool(entry and entry.local_sync_path)
# Determine MCP transport based on project routing mode
if entry and entry.mode == ProjectMode.CLOUD:
mcp_transport = "https"
@@ -628,8 +459,9 @@ def list_projects(
mcp_transport = "stdio"
# Show workspace name (type) for cloud-sourced projects
cloud_ws_name = cloud_workspace.name if cloud_workspace else None
cloud_ws_type = cloud_workspace.workspace_type if cloud_workspace else None
ws_label = ""
if cloud_project is not None and cloud_ws_name:
ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name
# display_name is a human label for private UUID-named projects (e.g., "My Project").
# Keep "name" as the canonical identifier for scripting/JSON consumers;
@@ -649,8 +481,8 @@ def list_projects(
}
if display_name:
row_data["display_name"] = display_name
if cloud_project is not None and cloud_ws_name:
row_data["workspace"] = cloud_ws_name
if ws_label:
row_data["workspace"] = cloud_ws_name or ""
if cloud_ws_type:
row_data["workspace_type"] = cloud_ws_type
@@ -682,20 +514,6 @@ def list_projects(
"[dim]Showing local projects only. "
"Run 'bm cloud login' or 'bm cloud api-key save <key>' if this is a credentials issue.[/dim]"
)
if cloud_workspace_error is not None:
console.print(
f"[yellow]Cloud workspace discovery failed: {cloud_workspace_error}[/yellow]"
)
console.print(
"[dim]Showing cloud projects from the configured/default workspace only.[/dim]"
)
for failed_workspace, error in failed_cloud_workspaces:
console.print(
f"[yellow]Cloud project discovery failed for workspace "
f"{failed_workspace.name}: {error}[/yellow]"
)
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error listing projects: {str(e)}[/red]")
raise typer.Exit(1)
@@ -713,7 +531,7 @@ def add_project(
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name, slug, type, or tenant_id (cloud mode only)",
help="Cloud workspace name or tenant_id (cloud mode only)",
),
visibility: str = typer.Option(
None,
@@ -1020,83 +838,13 @@ 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, slug, type, or tenant_id to associate with this project",
help="Cloud workspace name or tenant_id to associate with this project",
),
) -> None:
"""Set a project to cloud mode (route through cloud API).
@@ -1107,13 +855,6 @@ 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-...
@@ -1142,52 +883,27 @@ 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).
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.
Clears any associated cloud workspace.
Examples:
bm project set-local research --local-path ~/Documents/research
bm project set-local research # reuse prior path
Example:
bm project set-local research
"""
config_manager = ConfigManager()
config = config_manager.config
@@ -1197,31 +913,11 @@ 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]")
+32 -54
View File
@@ -62,12 +62,9 @@ def write_note(
help="The project to write to. If not provided, the default project will be used."
),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -109,7 +106,7 @@ def write_note(
content=content,
directory=folder,
project=project,
project_id=project_id,
workspace=workspace,
tags=tags,
output_format="json",
)
@@ -131,16 +128,15 @@ 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,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -153,6 +149,7 @@ 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)
@@ -162,7 +159,9 @@ def read_note(
mcp_read_note(
identifier=identifier,
project=project,
project_id=project_id,
workspace=workspace,
page=page,
page_size=page_size,
include_frontmatter=include_frontmatter,
output_format="json",
)
@@ -201,12 +200,9 @@ def edit_note(
help="The project to edit. If not provided, the default project will be used."
),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -231,7 +227,7 @@ def edit_note(
operation=operation,
content=content,
project=project,
project_id=project_id,
workspace=workspace,
section=section,
find_text=find_text,
expected_replacements=expected_replacements,
@@ -269,12 +265,9 @@ def build_context(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -296,7 +289,7 @@ def build_context(
mcp_build_context(
url=url,
project=project,
project_id=project_id,
workspace=workspace,
depth=depth,
timeframe=timeframe,
page=page,
@@ -329,12 +322,9 @@ def recent_activity(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -361,7 +351,7 @@ def recent_activity(
page=page,
page_size=page_size,
project=project,
project_id=project_id,
workspace=workspace,
output_format="json",
)
)
@@ -423,12 +413,9 @@ def search_notes(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -500,7 +487,7 @@ def search_notes(
mcp_search(
query=query or None,
project=project,
project_id=project_id,
workspace=workspace,
search_type=search_type,
output_format="json",
page=page,
@@ -610,12 +597,9 @@ def schema_validate(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -650,7 +634,7 @@ def schema_validate(
note_type=note_type,
identifier=identifier,
project=project,
project_id=project_id,
workspace=workspace,
output_format="json",
)
)
@@ -681,12 +665,9 @@ def schema_infer(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -710,7 +691,7 @@ def schema_infer(
note_type=note_type,
threshold=threshold,
project=project,
project_id=project_id,
workspace=workspace,
output_format="json",
)
)
@@ -738,12 +719,9 @@ def schema_diff(
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
workspace: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -765,7 +743,7 @@ def schema_diff(
mcp_schema_diff(
note_type=note_type,
project=project,
project_id=project_id,
workspace=workspace,
output_format="json",
)
)
-1
View File
@@ -24,7 +24,6 @@ if not _version_only_invocation(sys.argv[1:]):
import_claude_projects,
import_memory_json,
mcp,
orphans,
project,
schema,
status,
+24 -84
View File
@@ -21,27 +21,13 @@ from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = "basic-memory"
DATA_DIR_NAME = ".basic-memory"
CONFIG_FILE_NAME = "config.json"
WATCH_STATUS_JSON = "watch-status.json"
CONFIG_DIR_MODE = 0o700
CONFIG_FILE_MODE = 0o600
Environment = Literal["test", "dev", "user"]
def _secure_config_dir(path: Path) -> None:
"""Restrict config directory permissions on platforms with POSIX modes."""
if os.name != "nt":
path.chmod(CONFIG_DIR_MODE)
def _secure_config_file(path: Path) -> None:
"""Restrict config file permissions because config can contain cloud credentials."""
if os.name != "nt":
path.chmod(CONFIG_FILE_MODE)
class ProjectMode(str, Enum):
"""Per-project routing mode."""
@@ -64,47 +50,6 @@ 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."""
@@ -185,15 +130,13 @@ class BasicMemoryConfig(BaseSettings):
env: Environment = Field(default="dev", description="Environment name")
projects: Dict[str, ProjectEntry] = Field(
default_factory=lambda: (
{
"main": ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
}
if os.getenv("BASIC_MEMORY_HOME")
else {}
),
default_factory=lambda: {
"main": ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
}
if os.getenv("BASIC_MEMORY_HOME")
else {},
description="Mapping of project names to their ProjectEntry configuration",
)
default_project: Optional[str] = Field(
@@ -279,13 +222,7 @@ class BasicMemoryConfig(BaseSettings):
)
semantic_embedding_cache_dir: str | None = Field(
default=None,
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."
),
description="Optional cache directory for FastEmbed model artifacts.",
)
semantic_embedding_threads: int | None = Field(
default=None,
@@ -297,11 +234,6 @@ class BasicMemoryConfig(BaseSettings):
description="Optional FastEmbed embed() parallelism override.",
gt=0,
)
import_upload_max_bytes: int = Field(
default=100 * 1024 * 1024,
description="Maximum uploaded JSON export size accepted by API import endpoints.",
gt=0,
)
semantic_vector_k: int = Field(
default=100,
description="Vector candidate count for vector and hybrid retrieval.",
@@ -777,7 +709,11 @@ class BasicMemoryConfig(BaseSettings):
@property
def data_dir_path(self) -> Path:
"""Get app state directory for config and default SQLite database."""
return resolve_data_dir()
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
# Module-level cache for configuration
@@ -795,12 +731,20 @@ class ConfigManager:
def __init__(self) -> None:
"""Initialize the configuration manager."""
self.config_dir = resolve_data_dir()
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_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:
@@ -913,7 +857,6 @@ class ConfigManager:
# Create backup before overwriting so users can revert if needed
backup_path = self.config_file.with_suffix(".json.bak")
shutil.copy2(self.config_file, backup_path)
_secure_config_file(backup_path)
logger.info(f"Migrating config to current format (backup: {backup_path})")
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
@@ -1069,12 +1012,9 @@ def has_cloud_credentials(config: BasicMemoryConfig) -> bool:
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
_secure_config_dir(file_path.parent)
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
file_path.write_text(json.dumps(config_dict, indent=2))
_secure_config_file(file_path)
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+25 -4
View File
@@ -447,12 +447,8 @@ 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))
@@ -496,6 +492,7 @@ class LocalTaskScheduler:
async def get_task_scheduler(
entity_service: EntityServiceV2ExternalDep,
sync_service: SyncServiceV2ExternalDep,
search_service: SearchServiceV2ExternalDep,
project_config: ProjectConfigV2ExternalDep,
@@ -503,6 +500,28 @@ 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)
@@ -518,6 +537,8 @@ 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,
+3 -8
View File
@@ -4,8 +4,6 @@ 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
@@ -63,11 +61,9 @@ def get_bmignore_path() -> Path:
"""Get path to .bmignore file.
Returns:
Path to <basic-memory data dir>/.bmignore, honoring
``BASIC_MEMORY_CONFIG_DIR`` so isolated instances each keep their
own ignore file.
Path to ~/.basic-memory/.bmignore
"""
return resolve_data_dir() / ".bmignore"
return Path.home() / ".basic-memory" / ".bmignore"
def create_default_bmignore() -> None:
@@ -180,8 +176,7 @@ 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 data dir>/.bmignore (user's global ignore patterns, honors
BASIC_MEMORY_CONFIG_DIR)
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
Args:
-2
View File
@@ -12,7 +12,6 @@ from basic_memory.indexing.models import (
IndexingBatchResult,
IndexInputFile,
IndexProgress,
SyncedMarkdownFile,
)
__all__ = [
@@ -26,6 +25,5 @@ __all__ = [
"IndexingBatchResult",
"IndexInputFile",
"IndexProgress",
"SyncedMarkdownFile",
"build_index_batches",
]
+30 -190
View File
@@ -11,9 +11,8 @@ 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, remove_frontmatter
from basic_memory.file_utils import compute_checksum, has_frontmatter
from basic_memory.markdown.schemas import EntityMarkdown
from basic_memory.indexing.models import (
IndexedEntity,
@@ -44,19 +43,12 @@ 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."""
@@ -126,9 +118,6 @@ 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,
@@ -179,76 +168,6 @@ 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:
@@ -401,8 +320,34 @@ class BatchIndexer:
# --- Persistence ---
async def _upsert_markdown_file(self, prepared: _PreparedMarkdownFile) -> _PreparedEntity:
persisted = await self._persist_markdown_file(prepared)
return self._build_prepared_entity(persisted.prepared, persisted.entity)
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,
)
async def _upsert_regular_file(self, file: IndexInputFile) -> _PreparedEntity:
checksum = await self._resolve_checksum(file)
@@ -460,7 +405,6 @@ class BatchIndexer:
return _PreparedEntity(
path=file.path,
entity_id=updated.id,
permalink=updated.permalink,
checksum=checksum,
content_type=file.content_type,
search_content=None,
@@ -493,14 +437,8 @@ 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, strict=True
relation.to_name
)
if resolved_entity is None or resolved_entity.id == relation.from_id:
return 0
@@ -557,99 +495,6 @@ 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
@@ -678,11 +523,6 @@ 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"
+1 -17
View File
@@ -4,10 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Protocol, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from basic_memory.models import Entity
from typing import Any, Protocol
@dataclass(slots=True)
@@ -78,19 +75,6 @@ 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."""
+8 -45
View File
@@ -85,28 +85,6 @@ def parse_observation(token: Token) -> Dict[str, Any]:
# Relation handling functions
def parse_relation_type(content: str) -> str | None:
"""Return the explicit relation label before the first wikilink, if any."""
before_link = content.partition("[[")[0].strip()
if not before_link:
return None
# Trigger: relation labels that need spaces must be quoted.
# Why: unquoted multi-word prefixes are indistinguishable from prose
# containing a wikilink.
# Outcome: `some_type [[Target]]`, `"some type" [[Target]]`, and
# `'some type' [[Target]]` are explicit; `some other thing [[Target]]`
# falls back to inline `links_to` handling.
quote = before_link[0]
if quote in {"'", '"'} and before_link.endswith(quote):
quoted_label = before_link[1:-1].strip()
return quoted_label or None
if any(char.isspace() for char in before_link):
return None
return before_link
def is_explicit_relation(token: Token) -> bool:
"""Check if token looks like our relation format."""
if token.type != "inline": # pragma: no cover
@@ -114,7 +92,7 @@ def is_explicit_relation(token: Token) -> bool:
# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
return "[[" in content and "]]" in content and parse_relation_type(content) is not None
return "[[" in content and "]]" in content
def parse_relation(token: Token) -> Dict[str, Any] | None:
@@ -123,18 +101,20 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
rel_type = parse_relation_type(content)
if rel_type is None:
return None
# Extract [[target]]
target = None
rel_type = "relates_to" # default
context = None
start = content.find("[[")
end = content.find("]]", start + 2)
end = content.find("]]")
if start != -1 and end != -1:
# Get text before link as relation type
before = content[:start].strip()
if before:
rel_type = before
# Get target
target = normalize_project_reference(content[start + 2 : end].strip())
@@ -200,9 +180,6 @@ 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]
@@ -210,18 +187,6 @@ 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)
@@ -237,8 +202,6 @@ def relation_plugin(md: MarkdownIt) -> None:
Explicit relations:
- relation_type [[target]] (context)
- "multi word relation type" [[target]] (context)
- 'multi word relation type' [[target]] (context)
Implicit relations (links in content):
Some text with [[target]] reference
+10 -210
View File
@@ -1,36 +1,14 @@
import os
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 contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import AsyncIterator, Callable, Optional
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
import logfire
from basic_memory import telemetry
from basic_memory.api.app import app as fastapi_app
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."""
@@ -57,186 +35,16 @@ def _build_timeout() -> Timeout:
)
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=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]:
def _asgi_client(timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client."""
# Import on first local-client use so CLI help/version paths can import
# routing helpers without constructing the full FastAPI router graph.
from basic_memory.api.app import app as fastapi_app
# 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
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
async def _resolve_cloud_token(config) -> str:
"""Resolve cloud token with API key preferred, OAuth fallback."""
with logfire.span(
with telemetry.span(
"routing.resolve_cloud_credentials",
has_api_key=bool(config.cloud_api_key),
):
@@ -286,12 +94,9 @@ 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}")
@@ -443,12 +248,7 @@ def create_client() -> AsyncClient:
if _force_local_mode() or not _force_cloud_mode():
logger.info("Creating ASGI client for local Basic Memory API")
# 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)
return _asgi_client(timeout)
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
config = ConfigManager().config
+26 -30
View File
@@ -7,7 +7,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
from basic_memory import telemetry
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,7 +15,6 @@ from basic_memory.schemas.response import (
DirectoryMoveResult,
DirectoryDeleteResult,
)
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
class KnowledgeClient:
@@ -45,7 +44,9 @@ class KnowledgeClient:
# --- Entity CRUD Operations ---
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
async def create_entity(
self, entity_data: dict[str, Any], *, fast: bool | None = None
) -> EntityResponse:
"""Create a new entity.
Args:
@@ -57,15 +58,18 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
params = {"fast": fast} if fast is not None else None
with telemetry.scope(
"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",
@@ -76,6 +80,8 @@ class KnowledgeClient:
self,
entity_id: str,
entity_data: dict[str, Any],
*,
fast: bool | None = None,
) -> EntityResponse:
"""Update an existing entity (full replacement).
@@ -89,15 +95,18 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
params = {"fast": fast} if fast is not None else None
with telemetry.scope(
"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}",
@@ -116,7 +125,7 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.get_entity",
client_name="knowledge",
operation="get_entity",
@@ -134,6 +143,8 @@ class KnowledgeClient:
self,
entity_id: str,
patch_data: dict[str, Any],
*,
fast: bool | None = None,
) -> EntityResponse:
"""Partially update an entity.
@@ -147,15 +158,18 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
params = {"fast": fast} if fast is not None else None
with telemetry.scope(
"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}",
@@ -174,7 +188,7 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.delete_entity",
client_name="knowledge",
operation="delete_entity",
@@ -201,7 +215,7 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.move_entity",
client_name="knowledge",
operation="move_entity",
@@ -231,7 +245,7 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.move_directory",
client_name="knowledge",
operation="move_directory",
@@ -261,7 +275,7 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.delete_directory",
client_name="knowledge",
operation="delete_directory",
@@ -276,24 +290,6 @@ class KnowledgeClient:
)
return DirectoryDeleteResult.model_validate(response.json())
# --- Orphan detection ---
async def get_orphans(self) -> list[GraphNode]:
"""Get entities that have no incoming or outgoing relations."""
with logfire.span(
"mcp.client.knowledge.get_orphans",
client_name="knowledge",
operation="get_orphans",
):
response = await call_get(
self.http_client,
f"{self._base_path}/orphans",
client_name="knowledge",
operation="get_orphans",
path_template="/v2/projects/{project_id}/knowledge/orphans",
)
return OrphanEntitiesResponse.model_validate(response.json()).entities
# --- Resolution ---
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
@@ -309,7 +305,7 @@ class KnowledgeClient:
Raises:
ToolError: If the identifier cannot be resolved
"""
with logfire.span(
with telemetry.scope(
"mcp.client.knowledge.resolve_entity",
client_name="knowledge",
operation="resolve_entity",
+3 -3
View File
@@ -7,7 +7,7 @@ from typing import Optional
from httpx import AsyncClient
import logfire
from basic_memory import telemetry
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 logfire.span(
with telemetry.scope(
"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 logfire.span(
with telemetry.scope(
"mcp.client.memory.recent_activity",
client_name="memory",
operation="recent_activity",
+22 -3
View File
@@ -3,9 +3,11 @@
Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
"""
from typing import Optional
from httpx import AsyncClient, Response
import logfire
from basic_memory import telemetry
from basic_memory.mcp.tools.utils import call_get
@@ -37,11 +39,19 @@ class ResourceClient:
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/resource"
async def read(self, entity_id: str) -> Response:
async def read(
self,
entity_id: str,
*,
page: Optional[int] = None,
page_size: Optional[int] = None,
) -> 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)
@@ -49,14 +59,23 @@ class ResourceClient:
Raises:
ToolError: If the resource is not found or request fails
"""
with logfire.span(
params: dict = {}
if page is not None:
params["page"] = page
if page_size is not None:
params["page_size"] = page_size
with telemetry.scope(
"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}",
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
from basic_memory import telemetry
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 logfire.span(
with telemetry.scope(
"mcp.client.search.search",
client_name="search",
operation="search",
File diff suppressed because it is too large Load Diff
@@ -18,15 +18,13 @@ 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_id="<uuid>"` (preferred when known) or `project="name"` in tool calls
2. Explicit parameter: `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 (each entry includes external_id you can pass as project_id)
# Discover projects
projects = await list_memory_projects()
```
@@ -171,14 +169,6 @@ 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
+3 -3
View File
@@ -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
import logfire
from basic_memory import telemetry
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 logfire.span(
with telemetry.operation(
"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 logfire.span(
with telemetry.operation(
"mcp.lifecycle.shutdown",
entrypoint="mcp",
mode=container.mode.name.lower(),
+53 -80
View File
@@ -1,15 +1,14 @@
"""Build context tool for Basic Memory MCP server."""
from typing import Annotated, Optional, Literal
from typing import 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_memory_url_prefix,
detect_project_from_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -134,34 +133,14 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
url: Annotated[
MemoryUrl,
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
],
url: MemoryUrl,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
depth: str | int | None = 1,
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,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict | str:
@@ -179,9 +158,6 @@ 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"
@@ -209,14 +185,9 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
# 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,
)
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(url, ConfigManager().config)
if detected:
project = detected
@@ -231,12 +202,12 @@ async def build_context(
# URL is already validated and normalized by MemoryUrl type annotation
with logfire.span(
with telemetry.operation(
"mcp.tool.build_context",
entrypoint="mcp",
tool_name="build_context",
requested_project=project,
requested_project_id=project_id,
workspace_id=workspace,
depth=depth or 1,
timeframe=timeframe,
page=page,
@@ -245,46 +216,48 @@ async def build_context(
output_format=output_format,
is_memory_url=str(url).startswith("memory://"),
):
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}"
)
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}"
)
# 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()
+4 -13
View File
@@ -8,7 +8,7 @@ from typing import Annotated, Dict, List, Any, Optional
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from pydantic import BeforeValidator
from basic_memory.mcp.project_context import get_project_client
from basic_memory.utils import coerce_list
@@ -24,12 +24,9 @@ async def canvas(
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
title: str,
directory: Annotated[
str,
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
],
directory: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -46,9 +43,6 @@ 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)
@@ -103,10 +97,7 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{directory}/{file_title}"
+20 -29
View File
@@ -6,7 +6,7 @@ a list containing a single `{"type": "text", "text": "{...json...}"}` item.
"""
import json
from typing import Any, Dict, List, Optional, cast
from typing import Any, Dict, List, Optional
from fastmcp import Context
from loguru import logger
@@ -17,30 +17,18 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.search import SearchResponse, SearchResult
def _identifier_for_read_note(identifier: str) -> str:
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://") or "/" not in stripped:
return identifier
return f"memory://{stripped}"
def _format_search_results_for_chatgpt(
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
) -> List[Dict[str, Any]]:
"""Format search results according to ChatGPT's expected schema.
Returns a list of result objects with id, title, and url fields.
"""
if isinstance(results, SearchResponse):
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
elif isinstance(results, dict):
nested_results = results.get("results")
raw_results = (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
raw_results = nested_results if isinstance(nested_results, list) else []
else:
raw_results = results
@@ -125,7 +113,8 @@ async def search(
logger.info(f"ChatGPT search request: query='{query}'")
try:
# Keep this adapter tiny: the real search behavior lives in search_notes.
# Let search_notes resolve the default project via get_project_client(),
# which works in both local mode (ConfigManager) and cloud mode (database).
results = await search_notes(
query=query,
page=1,
@@ -134,6 +123,7 @@ async def search(
context=context,
)
# Handle string error responses from search_notes
if isinstance(results, str):
logger.warning(f"Search failed with error: {results[:100]}...")
search_results = {
@@ -141,17 +131,16 @@ async def search(
"error": "Search failed",
"error_details": results[:500], # Truncate long error messages
}
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
raw_results = results.get("results", []) if isinstance(results, dict) else []
formatted_results = _format_search_results_for_chatgpt(raw_results)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
else:
# Format successful results for ChatGPT
raw_results = results.get("results", []) if isinstance(results, dict) else []
formatted_results = _format_search_results_for_chatgpt(raw_results)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
# Return in MCP content array format as required by OpenAI
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
@@ -191,7 +180,9 @@ async def fetch(
# which works in both local mode (ConfigManager) and cloud mode (database).
content = str(
await read_note(
identifier=_identifier_for_read_note(id),
identifier=id,
page=1,
page_size=10,
context=context,
)
)
+10 -76
View File
@@ -1,21 +1,13 @@
from textwrap import dedent
from typing import Annotated, Optional, Literal
from typing import 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_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
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:
@@ -155,44 +147,15 @@ 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: Annotated[
bool,
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
] = False,
is_directory: bool = False,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> bool | str | dict:
@@ -216,9 +179,6 @@ async def delete_note(
(without file extensions). Defaults to False.
project: Project name to delete from. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
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.
@@ -264,23 +224,16 @@ 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/project_id was provided
# 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 project_id is None and identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
)
if project is None and identifier.strip().startswith("memory://"):
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if detected:
project = detected
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
logger.debug(
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
)
@@ -290,30 +243,11 @@ 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:
# 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)
result = await knowledge_client.delete_directory(identifier)
if output_format == "json":
return {
"deleted": result.failed_deletes == 0,
@@ -395,7 +329,7 @@ delete_note("path/to/file.md")
note_file_path = None
try:
# Resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
if output_format == "json":
entity = await knowledge_client.get_entity(entity_id)
note_title = entity.title
+239 -365
View File
@@ -1,28 +1,21 @@
"""Edit note tool for Basic Memory MCP server."""
from typing import Annotated, Optional, Literal
from typing import 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 (
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
detect_project_from_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.services.link_resolver import (
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
)
from basic_memory.utils import normalize_project_reference, validate_project_path
from basic_memory.utils import validate_project_path
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
@@ -52,58 +45,6 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]
return title, directory
def _compose_workspace_project_route(
*,
workspace: Optional[str],
project: Optional[str],
project_id: Optional[str],
) -> Optional[str]:
"""Return the explicit project route requested by workspace/project args."""
if workspace is None:
return project
cleaned_workspace = workspace.strip().strip("/")
if not cleaned_workspace:
raise ValueError("workspace must not be empty when provided")
if "/" in cleaned_workspace:
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
if project_id is not None:
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
if project is None or not project.strip().strip("/"):
raise ValueError("workspace requires an explicit project argument")
cleaned_project = project.strip().strip("/")
if "/" in cleaned_project:
raise ValueError(
"Use either workspace='workspace' with project='project', "
"or project='workspace/project', not both"
)
return f"{cleaned_workspace}/{cleaned_project}"
def _format_ambiguous_workspace_identifier_response(
*,
identifier: str,
detected_project: str,
) -> str:
"""Format the safe-stop response for ambiguous plain write identifiers."""
cleaned_identifier = identifier.strip()
normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/")
workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2)
return f"""# Edit Failed - Ambiguous Identifier
`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`.
Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path.
Retry with one of these explicit routes:
- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)`
- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""
def _format_error_response(
error_message: str,
operation: str,
@@ -229,34 +170,11 @@ Error editing note '{identifier}': {error_message}
async def edit_note(
identifier: str,
operation: 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")
),
],
content: str,
project: Optional[str] = None,
workspace: Optional[str] = None,
project_id: Optional[str] = None,
# Section/heading naming varies across tools; accept the descriptive forms.
section: Annotated[
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,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: Optional[int] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
@@ -282,13 +200,7 @@ async def edit_note(
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
content: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
Use "workspace/project" to route to a project in a specific cloud workspace.
If unknown, use list_memory_projects() to discover available projects.
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
routes as `workspace/project`. Cannot be combined with `project_id`.
project_id: Project external_id (UUID). Prefer this over `project` when known
it routes to the exact project regardless of name collisions across cloud
workspaces. Takes precedence over `project`. Get from list_memory_projects().
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)
@@ -348,292 +260,254 @@ async def edit_note(
"""
# Resolve effective default: allow MCP clients to send null for optional int field
effective_replacements = expected_replacements if expected_replacements is not None else 1
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)
# Resolve or reject routable identifier prefixes before selecting a client.
# Trigger: no explicit project/project_id was provided.
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
# are ambiguous for a mutating tool.
# Outcome: memory:// can route; plain workspace/project/path matches stop with
# guidance instead of silently editing another project.
if project is None and project_id is None:
config = ConfigManager().config
if identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
config,
context=context,
)
elif _workspace_identifier_discovery_available(
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
# 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)
if detected:
project = detected
with logfire.span(
with telemetry.operation(
"mcp.tool.edit_note",
entrypoint="mcp",
tool_name="edit_note",
requested_project=project,
requested_project_id=project_id,
workspace_id=workspace,
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, 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 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 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:
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
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,
directory=directory,
content_type="text/markdown",
content=content,
)
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:
# 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)
# --- 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'}",
]
# 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}'")
# 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
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}")
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 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()}"
f"MCP tool call tool=edit_note project={active_project.name} "
f"identifier={identifier} operation={operation} output_format={output_format}"
)
if output_format == "json":
return {
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"checksum": result.checksum,
"operation": operation,
"fileCreated": file_created,
}
# 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)}"
)
summary_result = "\n".join(summary)
return add_project_metadata(summary_result, active_project.name)
# 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")
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,
)
# Use the PATCH endpoint to edit the entity
try:
# 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)
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,
directory=directory,
content_type="text/markdown",
content=content,
)
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
)
# --- 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'}",
]
# 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}'")
# 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
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}")
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,
)
+5 -25
View File
@@ -1,10 +1,9 @@
"""List directory tool for Basic Memory MCP server."""
from typing import Annotated, Optional
from typing import 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
@@ -15,24 +14,11 @@ from basic_memory.mcp.server import mcp
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_directory(
# `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"),
),
] = "/",
dir_name: str = "/",
depth: int = 1,
file_name_glob: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("file_name_glob", "glob", "pattern", "filter"),
),
] = None,
file_name_glob: Optional[str] = None,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
) -> str:
"""List directory contents from the knowledge base with optional filtering.
@@ -50,9 +36,6 @@ 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:
@@ -80,10 +63,7 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
logger.debug(
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
)
+6 -31
View File
@@ -2,12 +2,11 @@
from pathlib import Path, PureWindowsPath
from textwrap import dedent
from typing import Annotated, Optional, Literal
from typing import 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
@@ -349,29 +348,11 @@ delete_note("{identifier}")
)
async def move_note(
identifier: str,
# 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,
destination_path: str = "",
destination_folder: Optional[str] = None,
is_directory: bool = False,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
@@ -396,9 +377,6 @@ async def move_note(
(without file extensions). Defaults to False.
project: Project name to move within. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
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.
@@ -498,10 +476,7 @@ 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, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
destination_target = destination_folder or destination_path
logger.info(
f"MCP tool call tool=move_note project={active_project.name} "
+91 -336
View File
@@ -10,23 +10,8 @@ from typing import Literal
from fastmcp import Context
from loguru import logger
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.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.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
from basic_memory.utils import generate_permalink
@@ -35,6 +20,30 @@ 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,
@@ -42,8 +51,6 @@ 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.
@@ -106,13 +113,9 @@ 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,
@@ -123,197 +126,21 @@ 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"]
external_id = project.get("external_id", "")
id_suffix = f" [{external_id}]" if external_id else ""
result += f"- {label} ({source}){id_suffix}\n"
result += f"{label} ({source})\n"
result += "\n" + "" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
@@ -345,6 +172,7 @@ 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.
@@ -352,14 +180,11 @@ 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
@@ -371,21 +196,18 @@ 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 cloud source
# Outcome: fetch every accessible workspace so callers can discover cross-workspace IDs
# Why: there is no local ASGI server; the factory IS the only source
# Outcome: single fetch, no merge needed
if is_factory_mode():
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,
)
async with get_client() as client:
project_client = ProjectClient(client)
project_list = await project_client.list_projects()
merged = _merge_projects(project_list, None)
if output_format == "json":
return _format_project_list_json(merged, default_project, constrained_project)
return _format_project_list_json(
merged, project_list.default_project, constrained_project
)
if constrained_project:
return _format_constrained_text(constrained_project)
return _format_project_list_text(merged)
@@ -398,40 +220,39 @@ 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):
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"
)
# Use explicit workspace, fall back to config default
effective_workspace = workspace or config.default_workspace
cloud_list = await _fetch_cloud_projects(effective_workspace, context)
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,
)
# 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,
)
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,
)
default_project = local_list.default_project
if output_format == "json":
@@ -451,32 +272,6 @@ 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},
@@ -485,7 +280,6 @@ 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:
@@ -498,11 +292,6 @@ async def create_memory_project(
project_name: Name for the new project (must be unique)
project_path: File system path where the project will be stored
set_default: Whether to set this project as the default (optional, defaults to False)
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.
@@ -513,36 +302,26 @@ 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")
"""
# 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}"`'
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}"`'
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}")
@@ -568,7 +347,6 @@ 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,
@@ -578,22 +356,17 @@ 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
@@ -607,7 +380,6 @@ 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:
@@ -622,11 +394,7 @@ async def create_memory_project(
@mcp.tool(
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(
project_name: str,
workspace: str | None = None,
context: Context | None = None,
) -> str:
async def delete_project(project_name: str, context: Context | None = None) -> str:
"""Delete a Basic Memory project.
Removes a project from the configuration and database. This does NOT delete
@@ -635,33 +403,23 @@ async def delete_project(
Args:
project_name: Name of the project to delete
workspace: Optional cloud workspace selector to delete the project from.
Slug is preferred for AI callers, but tenant_id and unique name are
also accepted. When omitted, the connection's default workspace is
used. In local mode the selector is passed through without slug
resolution, matching create_memory_project behavior.
Returns:
Confirmation message about project deletion
Example:
delete_project("old-project")
delete_project("team-project", workspace="team-paul")
Warning:
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
# Trigger: MCP server is constrained to a single project.
# Why: constrained sessions cannot delete projects, and workspace selectors
# may be invalid or unavailable in that locked context.
# Outcome: return the existing disabled message before opening a routed client.
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
workspace_id = await _resolve_workspace_routing(workspace, context)
async with get_client(workspace=workspace_id) as client:
if context: # pragma: no cover
await context.info(f"Deleting project: {project_name}")
@@ -693,9 +451,6 @@ async def delete_project(
# 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"
+10 -27
View File
@@ -8,17 +8,16 @@ Files are read directly without any knowledge graph processing.
import base64
import io
from typing import Annotated, Optional
from typing import 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_memory_url_prefix,
detect_project_from_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -159,12 +158,9 @@ def optimize_image(img, content_length, max_output_bytes=350000):
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_content(
path: Annotated[
str,
Field(validation_alias=AliasChoices("path", "file_path", "filepath", "file")),
],
path: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
) -> dict:
"""Read a file's raw content by path or permalink.
@@ -185,9 +181,6 @@ 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:
@@ -217,27 +210,17 @@ 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.
# 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,
)
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(path, ConfigManager().config)
if detected:
project = detected
logger.info(f"MCP tool call tool=read_content project={project} path={path}")
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)
async with get_project_client(project, workspace, context) as (client, active_project):
# Resolve path with project-prefix awareness for memory:// URLs
_, url, _ = await resolve_project_and_path(client, path, project, context)
# Validate path to prevent path traversal attacks
# For memory:// URLs, validate the extracted path (not the raw URL which
+214 -211
View File
@@ -3,15 +3,15 @@
from textwrap import dedent
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_identifier_prefix,
detect_project_from_url_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
lines = content.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
if not content.startswith("---\n"):
return original_content, None
lines = content.splitlines(keepends=True)
closing_index = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
@@ -70,7 +70,9 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
async def read_note(
identifier: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
output_format: Literal["text", "json"] = "text",
include_frontmatter: bool = False,
context: Context | None = None,
@@ -94,11 +96,10 @@ 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
@@ -119,6 +120,9 @@ 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")
@@ -130,240 +134,239 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
# Detect project from a memory URL or permalink prefix before routing.
# 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,
)
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if detected:
project = detected
with logfire.span(
with telemetry.operation(
"mcp.tool.read_note",
entrypoint="mcp",
tool_name="read_note",
requested_project=project,
requested_project_id=project_id,
workspace_id=workspace,
output_format=output_format,
page=page,
page_size=page_size,
include_frontmatter=include_frontmatter,
):
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
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",
):
logger.warning(
"Attempted path traversal attack blocked",
identifier=identifier,
processed_path=processed_path,
project=active_project.name,
# Resolve identifier with project-prefix awareness for memory:// URLs
_, entity_path, _ = await resolve_project_and_path(
client, identifier, project, context
)
if output_format == "json":
# 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:
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 _empty_json_payload() -> dict:
return {
"title": None,
"permalink": None,
"file_path": None,
"content": None,
"frontmatter": None,
}
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 _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)
]
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_title(item: dict[str, object]) -> str:
return str(item.get("title") or "")
def _result_permalink(item: dict[str, object]) -> Optional[str]:
value = item.get("permalink")
return str(value) if value else None
def _result_file_path(item: dict[str, object]) -> Optional[str]:
value = item.get("file_path")
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)
# Fetch content using entity ID
response = await resource_client.read(entity_id)
# If successful, return the content
if response.status_code == 200:
logger.info(
"Returning read_note result from resource: {path}",
path=entity_path,
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.
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,
)
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
return cast(dict[str, object], response) if isinstance(response, dict) else {}
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await _search_candidates(identifier, title_only=True)
def _result_title(item: dict[str, object]) -> str:
return str(item.get("title") or "")
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
)
def _result_permalink(item: dict[str, object]) -> Optional[str]:
value = item.get("permalink")
return str(value) if value else None
# Fetch content using the entity ID
response = await resource_client.read(entity_id)
def _result_file_path(item: dict[str, object]) -> Optional[str]:
value = item.get("file_path")
return str(value) if value else None
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
try:
# Try to resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
# Fetch content using entity ID
response = await resource_client.read(entity_id, page=page, page_size=page_size)
# If successful, return the content
if response.status_code == 200:
logger.info(
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
"Returning read_note result from resource: {path}", path=entity_path
)
else:
logger.info(
f"No results in title search for: {identifier} in project {active_project.name}"
)
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
# 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)
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await _search_candidates(identifier, title_only=True)
# We didn't find a direct match, construct a helpful error message
text_candidates = _search_results(text_results)
if not text_candidates:
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:
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":
return _empty_json_payload()
return format_not_found_message(active_project.name, identifier)
if output_format == "json":
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])
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:
+13 -41
View File
@@ -2,11 +2,10 @@
from datetime import timezone
from pathlib import PurePosixPath
from typing import Annotated, List, Union, Optional, Literal
from typing import 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 (
@@ -39,30 +38,13 @@ from basic_memory.schemas.search import SearchItemType
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def recent_activity(
type: Annotated[
Union[str, List[str]],
Field(default="", validation_alias=AliasChoices("type", "types", "kind")),
] = "",
type: Union[str, List[str]] = "",
depth: int = 1,
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,
timeframe: TimeFrame = "7d",
page: int = 1,
page_size: int = 10,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | list[dict]:
@@ -104,9 +86,6 @@ async def recent_activity(
project: Project name to query. Optional - server will resolve using the
hierarchy above. If unknown, use list_memory_projects() to discover
available projects.
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.
@@ -182,14 +161,9 @@ 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.
# 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
)
# Resolve project parameter using the three-tier hierarchy
# allow_discovery=True enables Discovery Mode, so a project is not required
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
@@ -304,7 +278,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, context=context, project_id=project_id) as (
async with get_project_client(resolved_project, workspace, context) as (
client,
active_project,
):
@@ -490,12 +464,10 @@ def _format_project_output(
elif result.primary_result.type == "observation":
observations.append(result.primary_result)
# 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).
# Show entities (notes/documents)
if entities:
lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
for entity in entities:
for entity in entities[:5]: # Show top 5
title = entity.title or "Untitled"
# Get folder from file_path
folder = ""
@@ -528,7 +500,7 @@ def _format_project_output(
# Show relations (connections)
if relations:
lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
for rel in relations:
for rel in relations[:5]: # Show top 5
rel_type = rel.relation_type
from_entity = rel.from_entity or "Unknown"
to_entity = rel.to_entity
+6 -24
View File
@@ -211,7 +211,7 @@ async def schema_validate(
note_type: Optional[str] = None,
identifier: Optional[str] = None,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> ValidationReport | str | dict:
@@ -236,9 +236,6 @@ 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:
@@ -254,10 +251,7 @@ async def schema_validate(
# Validate in a specific project
schema_validate(note_type="person", project="my-research")
"""
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
logger.info(
f"MCP tool call tool=schema_validate project={active_project.name} "
f"note_type={note_type} identifier={identifier}"
@@ -324,7 +318,7 @@ async def schema_infer(
note_type: str,
threshold: float = 0.25,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
@@ -348,9 +342,6 @@ 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:
@@ -366,10 +357,7 @@ async def schema_infer(
# Infer in a specific project
schema_infer("person", project="my-research")
"""
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
logger.info(
f"MCP tool call tool=schema_infer project={active_project.name} "
f"note_type={note_type} threshold={threshold}"
@@ -444,7 +432,7 @@ async def schema_infer(
async def schema_diff(
note_type: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
@@ -465,9 +453,6 @@ 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:
@@ -481,10 +466,7 @@ async def schema_diff(
# Check drift in a specific project
schema_diff("person", project="my-research")
"""
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
async with get_project_client(project, workspace, context) as (client, active_project):
logger.info(
f"MCP tool call tool=schema_diff project={active_project.name} note_type={note_type}"
)
+143 -482
View File
@@ -2,24 +2,18 @@
import re
from textwrap import dedent
from typing import Annotated, List, Optional, Dict, Any, Literal, cast
from uuid import UUID
from typing import Annotated, List, Optional, Dict, Any, Literal
import logfire
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from pydantic import BeforeValidator
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 import telemetry
from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_identifier_prefix,
detect_project_from_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -28,7 +22,6 @@ from basic_memory.schemas.search import (
SearchItemType,
SearchQuery,
SearchResponse,
SearchResult,
SearchRetrievalMode,
)
@@ -301,266 +294,6 @@ def _format_search_markdown(result: SearchResponse, project: str, query: str | N
return "\n".join(parts)
def _valid_project_id(value: object) -> str | None:
"""Return a UUID project id string when one is present."""
if not isinstance(value, str) or not value.strip():
return None
try:
return str(UUID(value))
except ValueError:
return None
def _matches_constrained_project(project: dict[str, Any], constrained_project: object) -> bool:
"""Return True when a project list row satisfies BASIC_MEMORY_MCP_PROJECT."""
if not isinstance(constrained_project, str) or not constrained_project.strip():
return True
candidates = {
value
for value in (
project.get("name"),
project.get("qualified_name"),
project.get("external_id"),
)
if isinstance(value, str)
}
return constrained_project in candidates
def _search_project_refs(projects_payload: object) -> list[dict[str, str | None]]:
"""Extract project routing refs for optional account-scoped search."""
if not isinstance(projects_payload, dict):
return []
payload = cast(dict[str, Any], projects_payload)
projects = payload.get("projects")
if not isinstance(projects, list):
return []
refs: list[dict[str, str | None]] = []
seen: set[tuple[str | None, str | None]] = set()
constrained_project = payload.get("constrained_project")
for item in projects:
if not isinstance(item, dict) or not _matches_constrained_project(
item, constrained_project
):
continue
project = item.get("qualified_name") or item.get("name")
project_name = project if isinstance(project, str) and project.strip() else None
project_id = _valid_project_id(item.get("external_id"))
if project_name is None and project_id is None:
continue
key = (project_name, project_id)
if key in seen:
continue
seen.add(key)
refs.append({"project": project_name, "project_id": project_id})
return refs
async def _load_search_project_refs(context: Context | None = None) -> list[dict[str, str | None]]:
"""Load accessible projects for search_all_projects without coupling the wrapper tool."""
from basic_memory.mcp.tools.project_management import list_memory_projects
return _search_project_refs(await list_memory_projects(output_format="json", context=context))
def _raw_results_from_search_payload(
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
) -> list[SearchResult | dict[str, Any]]:
"""Return the result list from any search_notes JSON-compatible payload."""
if isinstance(results, SearchResponse):
return list(results.results)
if isinstance(results, dict):
nested_results = results.get("results")
return (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
return list(results)
def _result_score(result: SearchResult | dict[str, Any]) -> float:
"""Return a comparable search score for merged project results."""
if isinstance(result, SearchResult):
return result.score
score = result.get("score")
return float(score) if isinstance(score, int | float) else 0.0
def _qualify_permalink_for_project(permalink: object, project: str | None) -> object:
"""Return a workspace-qualified permalink when the project ref supplies one."""
if not isinstance(permalink, str) or not permalink.strip():
return permalink
if not isinstance(project, str) or "/" not in project.strip("/"):
return permalink
normalized_permalink = permalink.strip("/")
qualified_project = project.strip("/")
if normalized_permalink == qualified_project or normalized_permalink.startswith(
f"{qualified_project}/"
):
return normalized_permalink
workspace_slug, project_permalink = qualified_project.split("/", 1)
return build_canonical_permalink(
project_permalink,
normalized_permalink,
include_project=True,
workspace_permalink=workspace_slug,
)
def _qualify_results_for_project(
results: list[SearchResult | dict[str, Any]],
project_ref: dict[str, str | None],
) -> list[dict[str, Any]]:
"""Attach the searched workspace/project prefix to each result permalink."""
qualified: list[dict[str, Any]] = []
for result in results:
if isinstance(result, SearchResult):
result_data = result.model_dump()
else:
result_data = dict(result)
result_data["permalink"] = _qualify_permalink_for_project(
result_data.get("permalink"),
project_ref.get("project"),
)
qualified.append(result_data)
return qualified
def _result_total(results: dict[str, Any], raw_results: list[SearchResult | dict[str, Any]]) -> int:
"""Return the best available total for a per-project search payload."""
total = results.get("total")
if isinstance(total, int) and total > 0:
return total
return len(raw_results) + (1 if results.get("has_more") is True else 0)
def _project_ref_label(project_ref: dict[str, str | None]) -> str:
"""Return a stable log label for a project search ref."""
return project_ref.get("project") or project_ref.get("project_id") or "<unknown project>"
async def _search_all_projects(
*,
query: str | None,
page: int,
page_size: int,
search_type: str | None,
output_format: Literal["text", "json"],
note_types: list[str],
entity_types: list[str],
after_date: str | None,
metadata_filters: dict[str, Any] | None,
tags: list[str] | None,
status: str | None,
min_similarity: float | None,
context: Context | None,
) -> dict | str:
"""Search every accessible project when the caller explicitly opts in."""
requested_page = max(page, 1)
requested_page_size = max(page_size, 1)
project_refs = await _load_search_project_refs(context=context)
if not project_refs:
response = SearchResponse(
results=[],
current_page=requested_page,
page_size=requested_page_size,
total=0,
has_more=False,
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
per_project_page_size = requested_page * requested_page_size
merged_results: list[dict[str, Any]] = []
total = 0
any_project_has_more = False
# Trigger: caller asked for an account-wide search.
# Why: project_id (external UUID) routes through the cloud v2 API path,
# which 401s on local installs because there's no JWT to present.
# Project names route through the local-ASGI path and work for both
# backends — cloud disambiguates names via the workspace/project
# qualified_name already baked into project_ref["project"].
# Outcome: forward project_id only when the same signals get_project_client
# uses to pick a cloud route are present. Mirrors the cloud_available
# composite in project_context.get_project_client (single source of
# truth for "can we route to cloud?").
config = ConfigManager().config
use_cloud_routing = (
is_factory_mode()
or (_explicit_routing() and not _force_local_mode())
or has_cloud_credentials(config)
)
for project_ref in project_refs:
recursive_project_id = project_ref["project_id"] if use_cloud_routing else None
try:
results = await search_notes(
query=query,
project=project_ref["project"],
project_id=recursive_project_id,
page=1,
page_size=per_project_page_size,
search_type=search_type,
output_format="json",
note_types=note_types or None,
entity_types=entity_types or None,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
search_all_projects=False,
context=context,
)
except Exception as exc:
logger.warning(
f"Multi-project search failed for project {_project_ref_label(project_ref)}: {exc}"
)
continue
if isinstance(results, str):
if not results.startswith("# Search Failed"):
return results
logger.warning(
"Multi-project search failed for project "
f"{_project_ref_label(project_ref)}: {results}"
)
continue
raw_results = _raw_results_from_search_payload(results)
total += _result_total(results, raw_results)
any_project_has_more = any_project_has_more or results.get("has_more") is True
merged_results.extend(_qualify_results_for_project(raw_results, project_ref))
sorted_results = sorted(merged_results, key=_result_score, reverse=True)
start = (requested_page - 1) * requested_page_size
end = start + requested_page_size
paged_results = sorted_results[start:end]
response = SearchResponse.model_validate(
{
"results": paged_results,
"current_page": requested_page,
"page_size": requested_page_size,
"total": total,
"has_more": any_project_has_more or total > end or len(sorted_results) > end,
}
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
# TODO: re-enable once MCP client rendering is working
@@ -568,58 +301,27 @@ async def _search_all_projects(
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_notes(
# 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,
query: Optional[str] = None,
project: Optional[str] = None,
project_id: Optional[str] = None,
search_all_projects: Annotated[
bool,
Field(
default=False,
validation_alias=AliasChoices("search_all_projects", "all_projects"),
),
] = False,
# `offset` is intentionally NOT aliased to `page`: offset is item-indexed
# (skip N items) while page is 1-indexed page-number. Direct aliasing would
# silently return the wrong slice.
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,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 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,
# 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,
after_date: Optional[str] = None,
metadata_filters: Annotated[
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
@@ -629,13 +331,7 @@ async def search_notes(
BeforeValidator(coerce_list),
] = None,
status: Optional[str] = None,
min_similarity: Annotated[
Optional[float],
Field(
default=None,
validation_alias=AliasChoices("min_similarity", "threshold", "similarity_threshold"),
),
] = None,
min_similarity: Optional[float] = None,
context: Context | None = None,
) -> dict | str:
"""Search across all content in the knowledge base with comprehensive syntax support.
@@ -647,8 +343,6 @@ async def search_notes(
Project Resolution:
Server resolves projects in this order: Single Project Mode project parameter default project.
If project unknown, use list_memory_projects() or recent_activity() first.
Set search_all_projects=True to search every accessible project; this is opt-in because it
performs one search per project.
## Search Syntax Examples
@@ -721,11 +415,6 @@ 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:
@@ -829,46 +518,18 @@ async def search_notes(
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
query = remainder or None
# Detect project from a memory URL or permalink prefix before routing.
# 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,
)
# 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)
if detected:
project = detected
# Trigger: caller explicitly requests account/workspace-wide search and did not
# already provide a concrete project route.
# Why: multi-project fan-out can be slow, so default search remains project-scoped.
# Outcome: run one normal search per accessible project and merge ranked results.
if search_all_projects and project is None and project_id is None:
all_projects_result = await _search_all_projects(
query=query,
page=page,
page_size=page_size,
search_type=search_type,
output_format=output_format,
note_types=note_types,
entity_types=entity_types,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
context=context,
)
return all_projects_result
with logfire.span(
with telemetry.operation(
"mcp.tool.search_notes",
entrypoint="mcp",
tool_name="search_notes",
requested_project=project,
requested_project_id=project_id,
search_all_projects=search_all_projects,
workspace_id=workspace,
search_type=search_type or "default",
output_format=output_format,
page=page,
@@ -882,140 +543,140 @@ async def search_notes(
has_tags_filter=bool(tags),
has_status_filter=bool(status),
):
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
)
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()
if is_memory_url:
query = resolved_query
effective_search_type = search_type or _default_search_type()
if is_memory_url:
effective_search_type = "permalink"
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))}"
# 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`."
)
# 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
# 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")]
# 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 returned no results for query: {query} in project {active_project.name}"
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}"
)
# 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
# Import here to avoid circular import (tools → clients → utils → tools)
from basic_memory.mcp.clients import SearchClient
if output_format == "json":
return result.model_dump(mode="json", exclude_none=True)
# 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}"
)
return _format_search_markdown(result, active_project.name, query)
# 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
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
)
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
)
+6 -4
View File
@@ -25,7 +25,6 @@ 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,
@@ -50,7 +49,6 @@ 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,
@@ -99,14 +97,16 @@ async def search_notes_ui(
async def read_note_ui(
identifier: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a note preview UI as an embedded MCP-UI resource."""
content = await read_note(
identifier=identifier,
project=project,
project_id=project_id,
page=page,
page_size=page_size,
output_format="text",
context=context,
)
@@ -114,6 +114,8 @@ async def read_note_ui(
render_data = {
"toolInput": {
"identifier": identifier,
"page": page,
"page_size": page_size,
},
"toolOutput": content,
}
+101 -87
View File
@@ -5,10 +5,10 @@ to the Basic Memory API, with improved error handling and logging.
"""
import typing
from typing import Any, Optional
from contextlib import contextmanager
from typing import Optional
import logfire
from httpx import Response, URL, AsyncClient, HTTPStatusError, Headers
from httpx import Response, URL, AsyncClient, HTTPStatusError
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
from httpx._types import (
RequestContent,
@@ -24,6 +24,7 @@ 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
@@ -40,38 +41,43 @@ def _classify_http_outcome(status_code: int) -> str:
return "unknown" # pragma: no cover
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),
}
class _RequestSpan:
"""Small adapter for attaching outcome metadata to a live request span."""
def __init__(self, active_span: typing.Any | None):
self._active_span = active_span
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_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 record_transport_error(self, exc: Exception) -> None:
self._set_attributes(
{
"is_success": False,
"outcome": "transport_error",
"error_type": type(exc).__name__,
}
)
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
def _set_attributes(self, attrs: dict[str, typing.Any]) -> None:
if self._active_span is None:
return
workspace_headers = workspace_permalink_headers()
if not workspace_headers:
return headers
set_attributes = getattr(self._active_span, "set_attributes", None)
if callable(set_attributes):
set_attributes(attrs)
return
if headers is None:
return workspace_headers
merged_headers = Headers(headers)
merged_headers.update(workspace_headers)
return merged_headers
set_attribute = getattr(self._active_span, "set_attribute", None)
if callable(set_attribute):
for key, value in attrs.items():
set_attribute(key, value)
def get_error_message(
@@ -124,20 +130,15 @@ def get_error_message(
return f"HTTP error {status_code}: {method} request to '{path}' failed"
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", ""):
def _extract_response_data(response: Response) -> typing.Any:
"""Safely decode response payload for error reporting."""
try:
return response.json()
except Exception:
return None
return response.json()
def _response_detail_text(response_data: Any) -> str | None:
def _response_detail_text(response_data: typing.Any) -> str | None:
"""Extract textual error detail from API payloads."""
if isinstance(response_data, dict):
detail = response_data.get("detail")
@@ -188,6 +189,31 @@ 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,
@@ -224,30 +250,27 @@ async def call_get(
"""
logger.debug(f"Calling GET '{url}' params: '{params}'")
error_message = None
request_span: logfire.LogfireSpan | None = None
request_span: _RequestSpan | None = None
try:
with logfire.span(
"mcp.http.request",
method="GET",
with _request_scope(
"GET",
client_name=client_name,
operation=operation,
path_template=path_template,
phase="request",
has_query=bool(params),
has_body=False,
params=params,
) as request_span:
response = await client.get(
url,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
request_span.set_attributes(_response_span_attrs(response))
request_span.record_response(response)
if response.is_success:
return response
@@ -276,7 +299,7 @@ async def call_get(
raise ToolError(error_message) from e
except Exception as e:
if request_span is not None:
request_span.set_attributes(_transport_error_span_attrs(e))
request_span.record_transport_error(e)
raise
@@ -324,17 +347,15 @@ async def call_put(
"""
logger.debug(f"Calling PUT '{url}'")
error_message = None
request_span: logfire.LogfireSpan | None = None
request_span: _RequestSpan | None = None
try:
with logfire.span(
"mcp.http.request",
method="PUT",
with _request_scope(
"PUT",
client_name=client_name,
operation=operation,
path_template=path_template,
phase="request",
has_query=bool(params),
params=params,
has_body=any(value is not None for value in (content, data, files, json)),
) as request_span:
response = await client.put(
@@ -344,14 +365,14 @@ async def call_put(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
request_span.set_attributes(_response_span_attrs(response))
request_span.record_response(response)
if response.is_success:
return response
@@ -381,7 +402,7 @@ async def call_put(
raise ToolError(error_message) from e
except Exception as e:
if request_span is not None:
request_span.set_attributes(_transport_error_span_attrs(e))
request_span.record_transport_error(e)
raise
@@ -428,17 +449,15 @@ async def call_patch(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling PATCH '{url}'")
request_span: logfire.LogfireSpan | None = None
request_span: _RequestSpan | None = None
try:
with logfire.span(
"mcp.http.request",
method="PATCH",
with _request_scope(
"PATCH",
client_name=client_name,
operation=operation,
path_template=path_template,
phase="request",
has_query=bool(params),
params=params,
has_body=any(value is not None for value in (content, data, files, json)),
) as request_span:
response = await client.patch(
@@ -448,14 +467,14 @@ async def call_patch(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
request_span.set_attributes(_response_span_attrs(response))
request_span.record_response(response)
if response.is_success:
return response
@@ -490,7 +509,7 @@ async def call_patch(
raise ToolError(error_message) from e
except Exception as e:
if request_span is not None:
request_span.set_attributes(_transport_error_span_attrs(e))
request_span.record_transport_error(e)
raise
@@ -538,17 +557,15 @@ async def call_post(
"""
logger.debug(f"Calling POST '{url}'")
error_message = None
request_span: logfire.LogfireSpan | None = None
request_span: _RequestSpan | None = None
try:
with logfire.span(
"mcp.http.request",
method="POST",
with _request_scope(
"POST",
client_name=client_name,
operation=operation,
path_template=path_template,
phase="request",
has_query=bool(params),
params=params,
has_body=any(value is not None for value in (content, data, files, json)),
) as request_span:
response = await client.post(
@@ -558,14 +575,14 @@ async def call_post(
files=files,
json=json,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
request_span.set_attributes(_response_span_attrs(response))
request_span.record_response(response)
logger.debug(f"response: {_extract_response_data(response)}")
if response.is_success:
@@ -595,7 +612,7 @@ async def call_post(
raise ToolError(error_message) from e
except Exception as e:
if request_span is not None:
request_span.set_attributes(_transport_error_span_attrs(e))
request_span.record_transport_error(e)
raise
@@ -667,30 +684,27 @@ async def call_delete(
"""
logger.debug(f"Calling DELETE '{url}'")
error_message = None
request_span: logfire.LogfireSpan | None = None
request_span: _RequestSpan | None = None
try:
with logfire.span(
"mcp.http.request",
method="DELETE",
with _request_scope(
"DELETE",
client_name=client_name,
operation=operation,
path_template=path_template,
phase="request",
has_query=bool(params),
has_body=False,
params=params,
) as request_span:
response = await client.delete(
url=url,
params=params,
headers=_request_headers(headers),
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
request_span.set_attributes(_response_span_attrs(response))
request_span.record_response(response)
if response.is_success:
return response
@@ -719,5 +733,5 @@ async def call_delete(
raise ToolError(error_message) from e
except Exception as e:
if request_span is not None:
request_span.set_attributes(_transport_error_span_attrs(e))
request_span.record_transport_error(e)
raise
+11 -5
View File
@@ -17,7 +17,9 @@ from basic_memory.mcp.tools.read_note import read_note
async def view_note(
identifier: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
context: Context | None = None,
) -> str:
"""View a markdown note as a formatted artifact.
@@ -30,9 +32,8 @@ 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.
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().
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
context: Optional FastMCP context for performance caching.
Returns:
@@ -45,6 +46,9 @@ 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")
@@ -59,7 +63,9 @@ async def view_note(
await read_note(
identifier=identifier,
project=project,
project_id=project_id,
workspace=workspace,
page=page,
page_size=page_size,
context=context,
)
)
+29 -43
View File
@@ -6,42 +6,6 @@ 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(
@@ -60,18 +24,40 @@ 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 response.model_dump(mode="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),
}
lines = [f"# Available Workspaces ({response.count})", ""]
for workspace in response.workspaces:
default_label = ", default" if workspace.is_default else ""
if not workspaces:
return (
"# No Workspaces Available\n\n"
"No accessible workspaces were found for this account. "
"Ensure the account has an active subscription and tenant access."
)
lines = [
f"# Available Workspaces ({len(workspaces)})",
"",
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
"",
]
for workspace in workspaces:
lines.append(
f"- {workspace.name} "
f"(slug={workspace.slug}, type={workspace.workspace_type}, "
f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})"
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
)
return "\n".join(lines)
+164 -184
View File
@@ -3,22 +3,16 @@
import textwrap
from typing import Annotated, List, Union, Optional, Literal
import logfire
from loguru import logger
from pydantic import AliasChoices, BeforeValidator, Field
from pydantic import BeforeValidator
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 (
build_qualified_permalink_reference,
coerce_dict,
parse_tags,
validate_project_path,
)
from basic_memory.workspace_context import current_workspace_permalink_context
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -31,13 +25,9 @@ TagType = Union[List[str], str, None]
async def write_note(
title: str,
content: str,
# Folder/dir/path are interchangeable in models' training data.
directory: Annotated[
str,
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
],
directory: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
workspace: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
@@ -68,14 +58,10 @@ async def write_note(
Relations format:
- Explicit: `- relation_type [[Entity]] (optional context)`
- Quoted: `- "multi word relation type" [[Entity]] (optional context)`
- Quoted: `- 'multi word relation type' [[Entity]] (optional context)`
- Inline: Any other `[[Entity]]` reference creates a `links_to` relation
- Inline: Any `[[Entity]]` reference creates a relation
Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- "based on" [[Design Notes]]`
`- 'in response to' [[Incident Review]]`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
@@ -88,9 +74,6 @@ 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".
@@ -166,183 +149,180 @@ async def write_note(
overwrite if overwrite is not None else ConfigManager().config.write_note_overwrite_default
)
with logfire.span(
with telemetry.operation(
"mcp.tool.write_note",
entrypoint="mcp",
tool_name="write_note",
requested_project=project,
requested_project_id=project_id,
workspace_id=workspace,
note_type=note_type,
overwrite=effective_overwrite,
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=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
)
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}"
)
# 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",
# 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,
directory=directory,
project=active_project.name,
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}"
)
if output_format == "json":
return {
"title": title,
"permalink": None,
"file_path": None,
"checksum": None,
"action": "created",
"error": "SECURITY_VALIDATION_ERROR",
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"checksum": result.checksum,
"action": action.lower(),
}
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,
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)
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:
+3 -3
View File
@@ -62,7 +62,7 @@ class Entity(Base):
)
# Core identity
id: Mapped[int] = mapped_column(Integer, primary_key=True) # pyright: ignore [reportIncompatibleVariableOverride]
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# 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) # pyright: ignore [reportIncompatibleVariableOverride]
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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) # pyright: ignore [reportIncompatibleVariableOverride]
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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, default_fastembed_cache_dir
from basic_memory.config import BasicMemoryConfig
from basic_memory.repository.embedding_provider import EmbeddingProvider
type ProviderCacheKey = tuple[
@@ -12,7 +12,7 @@ type ProviderCacheKey = tuple[
int | None,
int,
int,
str,
str | None,
int | None,
int | None,
]
@@ -22,20 +22,6 @@ _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)
@@ -75,12 +61,7 @@ def _resolve_fastembed_runtime_knobs(
def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
"""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.
"""
"""Build a stable cache key from provider-relevant semantic embedding config."""
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
return (
app_config.semantic_embedding_provider.strip().lower(),
@@ -88,7 +69,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,
_resolve_cache_dir(app_config),
app_config.semantic_embedding_cache_dir,
resolved_threads,
resolved_parallel,
)
@@ -122,12 +103,8 @@ 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)
# 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 app_config.semantic_embedding_cache_dir is not None:
extra_kwargs["cache_dir"] = app_config.semantic_embedding_cache_dir
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 exists, func, select
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import load_only, selectinload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from sqlalchemy.engine import Row
@@ -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, *, load_relations: bool = True) -> Optional[Entity]:
async def get_by_id(self, entity_id: int) -> Optional[Entity]: # pragma: no cover
"""Get entity by numeric ID.
Args:
@@ -43,10 +43,6 @@ 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]:
@@ -178,24 +174,6 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Entity]:
"""Fetch minimal entity fields needed for context hydration.
Context hydration only needs an entity's primary key, title, and external
UUID. Keeping this separate from find_by_ids avoids the relationship eager
loads that are useful for full entity reads but expensive for response shaping.
"""
if not ids:
return []
query = (
self.select()
.where(Entity.id.in_(ids))
.options(load_only(Entity.id, Entity.title, Entity.external_id))
)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_permalink_to_file_path_map(self) -> dict[str, str]:
"""Get a mapping of permalink -> file_path for all entities.
@@ -454,22 +432,6 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def find_without_relations(self) -> Sequence[Entity]:
"""Find entities that have no incoming or outgoing relations."""
# Trigger: entity appears as a source in any relation.
# Why: even unresolved outgoing links mean the entity references another node.
# Outcome: entities with outgoing relations are excluded from the orphan list.
has_outgoing = exists().where(Relation.from_id == Entity.id)
# Trigger: entity appears as the resolved target in any relation.
# Why: only resolved relation targets are graph nodes with an incoming edge.
# Outcome: entities referenced by resolved links are excluded from orphans.
has_incoming = exists().where(Relation.to_id == Entity.id)
query = self.select().where(~has_outgoing).where(~has_incoming).order_by(Entity.file_path)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_distinct_directories(self) -> List[str]:
"""Extract unique directory paths from file_path column.
@@ -5,17 +5,11 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
import re
from typing import Any, Iterable, List, cast
from typing import Any, Iterable, List
_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
_NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$")
_COMPARISON_OPERATORS = {
"$gt": "gt",
"$gte": "gte",
"$lt": "lt",
"$lte": "lte",
}
@dataclass(frozen=True)
@@ -54,11 +48,6 @@ def _normalize_scalar(value: Any) -> Any:
return value
def _normalize_numeric(value: object) -> float:
"""Normalize a value already proven numeric by _is_numeric_value."""
return float(cast(str | int | float, value))
def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter]:
"""Parse metadata filters into normalized clauses.
@@ -84,12 +73,7 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
if isinstance(raw_value, dict):
if len(raw_value) != 1:
raise ValueError(f"Invalid metadata filter for '{raw_key}': {raw_value}")
raw_op, value = next(iter(raw_value.items()))
if not isinstance(raw_op, str):
raise ValueError(
f"Unsupported operator '{raw_op}' in metadata filter for '{raw_key}'"
)
op = raw_op
op, value = next(iter(raw_value.items()))
if op == "$in":
if not isinstance(value, list) or not value:
@@ -99,20 +83,15 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
)
continue
if op in _COMPARISON_OPERATORS:
if op in {"$gt", "$gte", "$lt", "$lte"}:
if _is_numeric_value(value):
normalized = _normalize_numeric(value)
normalized = float(value)
comparison = "numeric"
else:
normalized = _normalize_scalar(value)
comparison = "text"
parsed.append(
ParsedMetadataFilter(
path_parts,
_COMPARISON_OPERATORS[op],
normalized,
comparison,
)
ParsedMetadataFilter(path_parts, op.lstrip("$"), normalized, comparison)
)
continue
@@ -120,7 +99,7 @@ def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter
if not isinstance(value, list) or len(value) != 2:
raise ValueError(f"$between requires [min, max] for '{raw_key}'")
if _is_numeric_collection(value):
normalized = [_normalize_numeric(v) for v in value]
normalized = [float(v) for v in value]
comparison = "numeric"
else:
normalized = [_normalize_scalar(v) for v in value]
@@ -686,17 +686,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
# FTS search (Postgres-specific)
# ------------------------------------------------------------------
@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(
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
@@ -706,8 +696,31 @@ class PostgresSearchRepository(SearchRepositoryBase):
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str, str]:
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
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) ---
conditions = []
params = {}
order_by_clause = ""
@@ -774,8 +787,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
# Handle date filter
if after_date:
params["after_date"] = after_date
# Filter on updated_at so recently-edited notes are included even when created_at is old
conditions.append("search_index.updated_at > :after_date")
conditions.append("search_index.created_at > :after_date")
# order by most recent first
order_by_clause = ", search_index.updated_at DESC"
@@ -856,6 +868,10 @@ 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"
@@ -868,64 +884,6 @@ 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,
@@ -946,7 +904,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
{score_expr} as score
FROM {from_clause}
WHERE {where_clause}
ORDER BY score DESC {order_by_clause}, search_index.id ASC
ORDER BY score DESC, search_index.id ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
@@ -957,7 +915,17 @@ class PostgresSearchRepository(SearchRepositoryBase):
result = await session.execute(text(sql), params)
rows = result.fetchall()
except Exception as e:
if self._is_tsquery_syntax_error(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
):
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
return []
@@ -998,60 +966,3 @@ 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,9 +4,7 @@ from pathlib import Path
from typing import Optional, Sequence, Union
from loguru import logger
from sqlalchemy import inspect as sa_inspect, select, text
from sqlalchemy.exc import NoResultFound, OperationalError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
@@ -14,65 +12,6 @@ from basic_memory.models.project import Project
from basic_memory.repository.repository import Repository
async def _load_sqlite_vec_on_session(session) -> bool:
"""Ensure the sqlite-vec extension is loaded on this session's connection.
Returns True when vec0 is available after the call. Returns False when the
extension can't be loaded on this Python build (e.g., python.org macOS or
Windows interpreters without `enable_load_extension`) every connection in
the pool shares the same interpreter, so a False here also means no
embedding row could ever have been written, and skipping the embeddings
purge is safe.
Mirrors SQLiteSearchRepository._ensure_sqlite_vec_loaded but as a free
function: we don't have a SearchRepository instance during project delete,
and the per-connection nature of extension loading means a pooled connection
routed to this session might not have vec loaded even when other
connections wrote embeddings.
"""
try:
await session.execute(text("SELECT vec_version()"))
return True
except OperationalError:
pass
try:
import sqlite_vec # type: ignore[import-not-found]
except ImportError:
logger.debug("sqlite-vec package not installed; skipping vec purge")
return False
async_connection = await session.connection()
raw_connection = await async_connection.get_raw_connection()
driver_connection = raw_connection.driver_connection
if not hasattr(driver_connection, "enable_load_extension"):
# Trigger: CPython build without sqlite extension support (#711).
# Why: load_extension is unavailable, so no connection in this pool
# can host vec0. No embeddings exist anywhere.
# Outcome: skip the embeddings purge entirely.
logger.debug(
"Skipping search_vector_embeddings purge: this Python build does "
"not support SQLite extension loading"
)
return False
try:
await driver_connection.enable_load_extension(True)
await driver_connection.load_extension(sqlite_vec.loadable_path())
await driver_connection.enable_load_extension(False)
await session.execute(text("SELECT vec_version()"))
except Exception as exc:
logger.warning(
"Failed to load sqlite-vec for project delete cleanup; "
"skipping embeddings purge: {}",
exc,
)
return False
return True
class ProjectRepository(Repository[Project]):
"""Repository for Project model.
@@ -182,83 +121,6 @@ class ProjectRepository(Repository[Project]):
return target_project
return None # pragma: no cover
async def delete(self, entity_id: int) -> bool:
"""Delete a project and its derived search rows in one transaction.
The cascade picture differs by backend:
- search_index project: Postgres has ON DELETE CASCADE FK; SQLite
stores search_index as an FTS5 virtual table and can't carry FKs,
so it needs explicit cleanup.
- search_vector_chunks project: neither backend has an FK here, so
both need an explicit DELETE.
- search_vector_embeddings search_vector_chunks: Postgres has an FK
(chunk_id REFERENCES ON DELETE CASCADE); SQLite stores embeddings
in a vec0 virtual table keyed by rowid with no cascade. On SQLite
the embeddings must be purged before the chunk rows, otherwise
`_run_vector_query` keeps returning stale vectors that crowd out
live results.
Each derived table is created lazily (search_index by
SearchRepository.init_search_index, the vector tables once semantic
search initializes), so any of them may be absent on minimal test DBs.
Inspect the connection once and skip whichever is missing.
"""
logger.debug(f"Deleting Project and search rows for project_id: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
project = result.scalars().one()
except NoResultFound:
logger.debug(f"No Project found to delete: {entity_id}")
return False
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
is_sqlite = dialect_name == "sqlite"
existing_tables = await session.run_sync(
lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names())
)
# search_index: SQLite has no FK on the FTS5 virtual table; Postgres
# cascades from the project FK, so the explicit DELETE is redundant.
if is_sqlite and "search_index" in existing_tables:
await session.execute(
text("DELETE FROM search_index WHERE project_id = :project_id"),
{"project_id": entity_id},
)
# search_vector_chunks: no FK to project on either backend, so both
# backends need this. SQLite must purge vec0 embeddings first
# (rowid pseudocolumn — Postgres uses chunk_id and would 500 here);
# Postgres' chunk_id FK CASCADE handles its embeddings cleanup when
# we delete the chunk rows below.
if "search_vector_chunks" in existing_tables:
if is_sqlite and "search_vector_embeddings" in existing_tables:
# Extension loading is per-connection. We must load vec0 on
# *this* session before the DELETE; otherwise a different
# pooled connection might have written embeddings that we'd
# silently leave behind.
if await _load_sqlite_vec_on_session(session):
await session.execute(
text(
"DELETE FROM search_vector_embeddings WHERE rowid IN ("
"SELECT id FROM search_vector_chunks "
"WHERE project_id = :project_id)"
),
{"project_id": entity_id},
)
await session.execute(
text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"),
{"project_id": entity_id},
)
await session.delete(project)
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
return True
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.
+3 -37
View File
@@ -13,7 +13,6 @@ from sqlalchemy import (
Result,
and_,
delete,
update as sqlalchemy_update,
)
from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import NoResultFound
@@ -141,20 +140,6 @@ 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.
@@ -295,9 +280,9 @@ class Repository[T: Base]:
if isinstance(entity_data, dict):
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])
for key, value in update_data.items():
if key in self.valid_columns:
setattr(entity, key, value)
elif isinstance(entity_data, self.Model):
for column in self.valid_columns:
@@ -313,25 +298,6 @@ 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,22 +50,6 @@ 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,12 +12,11 @@ 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
from basic_memory import db, telemetry
from basic_memory.repository.embedding_provider import EmbeddingProvider
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.semantic_errors import (
@@ -247,24 +246,6 @@ 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)
# ------------------------------------------------------------------
@@ -864,7 +845,7 @@ class SearchRepositoryBase(ABC):
progress_callback(entity_id, completed_entities, total_entities)
prepare_window_size = self._vector_prepare_window_size()
with logfire.span(
with telemetry.started_span(
"basic_memory.vector_sync.batch",
project_id=self.project_id,
backend=backend_name,
@@ -1087,42 +1068,48 @@ class SearchRepositoryBase(ABC):
write_seconds_total=result.write_seconds_total,
)
batch_total_seconds = time.perf_counter() - batch_start
batch_attrs = {
"backend": backend_name,
"skip_only_batch": result.embedding_jobs_total == 0,
}
logfire.metric_histogram("vector_sync_batch_total_seconds", unit="s").record(
batch_total_seconds, attributes=batch_attrs
telemetry.record_histogram(
"vector_sync_batch_total_seconds",
batch_total_seconds,
unit="s",
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
logfire.metric_histogram("vector_sync_prepare_seconds", unit="s").record(
result.prepare_seconds_total, attributes=batch_attrs
telemetry.add_counter(
"vector_sync_entities_total",
result.entities_total,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
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_skipped",
result.entities_skipped,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
logfire.metric_histogram("vector_sync_embed_seconds", unit="s").record(
result.embed_seconds_total, attributes=batch_attrs
telemetry.add_counter(
"vector_sync_entities_deferred",
result.entities_deferred,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
logfire.metric_histogram("vector_sync_write_seconds", unit="s").record(
result.write_seconds_total, attributes=batch_attrs
telemetry.add_counter(
"vector_sync_embedding_jobs_total",
result.embedding_jobs_total,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
logfire.metric_counter("vector_sync_entities_total").add(
result.entities_total, attributes=batch_attrs
telemetry.add_counter(
"vector_sync_chunks_total",
result.chunks_total,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
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
telemetry.add_counter(
"vector_sync_chunks_skipped",
result.chunks_skipped,
backend=backend_name,
skip_only_batch=result.embedding_jobs_total == 0,
)
if batch_span is not None:
batch_span.set_attributes(
@@ -1695,12 +1682,36 @@ class SearchRepositoryBase(ABC):
shard_count: int,
remaining_jobs_after_shard: int,
) -> None:
"""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.
"""
"""Log completion and slow-entity warnings with a consistent format."""
backend_name = type(self).__name__.removesuffix("SearchRepository").lower()
telemetry.record_histogram(
"vector_sync_prepare_seconds",
prepare_seconds,
unit="s",
backend=backend_name,
skip_only_entity=entity_skipped and embedding_jobs_count == 0,
)
telemetry.record_histogram(
"vector_sync_queue_wait_seconds",
queue_wait_seconds,
unit="s",
backend=backend_name,
skip_only_entity=entity_skipped and embedding_jobs_count == 0,
)
telemetry.record_histogram(
"vector_sync_embed_seconds",
embed_seconds,
unit="s",
backend=backend_name,
skip_only_entity=entity_skipped and embedding_jobs_count == 0,
)
telemetry.record_histogram(
"vector_sync_write_seconds",
write_seconds,
unit="s",
backend=backend_name,
skip_only_entity=entity_skipped and embedding_jobs_count == 0,
)
if total_seconds > 10:
logger.warning(
"Vector sync slow entity: project_id={project_id} entity_id={entity_id} "
@@ -94,22 +94,9 @@ class SQLiteSearchRepository(SearchRepositoryBase):
raise e
# Fail fast: create vector tables at startup so missing sqlite-vec
# 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.
# or embedding provider errors surface immediately
if self._semantic_enabled:
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
await self._ensure_vector_tables()
# ------------------------------------------------------------------
# FTS5 query preparation (backend-specific)
@@ -387,25 +374,6 @@ 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)
@@ -620,25 +588,6 @@ 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()
@@ -720,11 +669,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
# FTS search (backend-specific)
# ------------------------------------------------------------------
@staticmethod
def _is_fts5_syntax_error(exc: Exception) -> bool:
return "fts5: syntax error" in str(exc).lower()
async def _build_fts_query_parts(
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
@@ -734,8 +679,31 @@ class SQLiteSearchRepository(SearchRepositoryBase):
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
metadata_filters: Optional[dict] = None,
) -> tuple[str, str, dict, str]:
"""Build SQLite FTS FROM/WHERE params shared by search and count."""
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) ---
conditions = []
match_conditions = []
params = {}
@@ -808,8 +776,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
# Handle date filter using datetime() for proper comparison
if after_date:
params["after_date"] = after_date
# Filter on updated_at so recently-edited notes are included even when created_at is old
conditions.append("datetime(search_index.updated_at) > datetime(:after_date)")
conditions.append("datetime(search_index.created_at) > datetime(:after_date)")
# order by most recent first
order_by_clause = ", search_index.updated_at DESC"
@@ -912,60 +879,13 @@ 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,
@@ -998,7 +918,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
rows = result.fetchall()
except Exception as e:
# Handle FTS5 syntax errors and provide user-friendly feedback
if self._is_fts5_syntax_error(e): # pragma: no cover
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
# Return empty results rather than crashing
return []
@@ -1036,54 +956,3 @@ 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
+20 -67
View File
@@ -8,9 +8,7 @@ Syntax reference:
field: type, description # required field
field?: type, description # optional field
field(array): type # array of values
field(array, description): type # array with description
field?(enum): [val1, val2] # enumeration
field?(enum, description): [val1, val2] # enum with description
field?(object): # nested object
sub_field: type
EntityName as type (capitalized) # entity reference
@@ -60,88 +58,46 @@ class SchemaDefinition:
# with an uppercase letter is treated as an entity reference.
SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean", "any"})
MODIFIER_TYPES = frozenset({"array", "enum", "object"})
# --- Field Name Parsing ---
def _parse_field_key_parts(key: str) -> tuple[str, bool, bool, bool, bool, str | None]:
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
"""Parse a Picoschema field key into its components.
Returns (name, required, is_array, is_enum, is_object, description).
The key format is: name[?][(array|enum|object[, description])]
Returns (name, required, is_array, is_enum, is_object).
The key format is: name[?][(array|enum|object)]
Examples:
"name" -> ("name", True, False, False, False, None)
"role?" -> ("role", False, False, False, False, None)
"tags?(array)" -> ("tags", False, True, False, False, None)
"tags?(array, labels)" -> ("tags", False, True, False, False, "labels")
"status?(enum)" -> ("status", False, False, True, False, None)
"metadata?(object)" -> ("metadata", False, False, False, True, None)
"name" -> ("name", True, False, False)
"role?" -> ("role", False, False, False)
"tags?(array)" -> ("tags", False, True, False)
"status?(enum)" -> ("status", False, False, True)
"metadata?(object)" -> ("metadata", False, False, False) + children
"""
required = True
is_array = False
is_enum = False
is_object = False
description = None
key, modifier, description = _split_modifier_suffix(key)
if modifier == "array":
# Check for modifier suffix: (array), (enum), (object)
if key.endswith("(array)"):
is_array = True
elif modifier == "enum":
key = key[: -len("(array)")]
elif key.endswith("(enum)"):
is_enum = True
elif modifier == "object":
key = key[: -len("(enum)")]
elif key.endswith("(object)"):
is_object = True
key = key[: -len("(object)")]
# Check for optional marker
if key.endswith("?"):
required = False
key = key[:-1]
return key.strip(), required, is_array, is_enum, is_object, description
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
"""Parse a Picoschema field key, discarding any modifier description."""
name, required, is_array, is_enum, is_object, _description = _parse_field_key_parts(key)
return name, required, is_array, is_enum, is_object
def _split_modifier_suffix(key: str) -> tuple[str, str | None, str | None]:
"""Split a trailing picoschema modifier from a field key."""
stripped_key = key.rstrip()
if not stripped_key.endswith(")"):
return key, None, None
# Trigger: field names and modifier descriptions may both contain parentheses
# Why: only the parenthesis paired with the final suffix can introduce a modifier
# Outcome: preserves names like "risk(score)" and descriptions like "labels (freeform)"
open_paren_index = -1
depth = 0
for index in range(len(stripped_key) - 1, -1, -1):
char = stripped_key[index]
if char == ")":
depth += 1
elif char == "(":
depth -= 1
if depth == 0:
open_paren_index = index
break
if open_paren_index == -1:
return key, None, None
modifier_text = stripped_key[open_paren_index + 1 : -1].strip()
modifier, separator, description = modifier_text.partition(",")
modifier = modifier.strip()
if modifier not in MODIFIER_TYPES:
return key, None, None
key_without_modifier = stripped_key[:open_paren_index].rstrip()
parsed_description = description.strip() if separator else None
return key_without_modifier, modifier, parsed_description or None
return key, required, is_array, is_enum, is_object
def _parse_type_and_description(value: str) -> tuple[str, str | None]:
@@ -214,7 +170,7 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
fields: list[SchemaField] = []
for key, value in yaml_dict.items():
name, required, is_array, is_enum, is_object, key_description = _parse_field_key_parts(key)
name, required, is_array, is_enum, is_object = _parse_field_key(key)
# --- Enum fields ---
# Trigger: value is a list or a string containing bracketed enum values
@@ -223,12 +179,11 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
# in YAML to avoid parse errors)
# Outcome: SchemaField with is_enum=True and enum_values populated
if is_enum:
description = key_description
description = None
if isinstance(value, list):
enum_values = [str(v) for v in value]
else:
enum_values, value_description = _parse_enum_string(str(value))
description = description or value_description
enum_values, description = _parse_enum_string(str(value))
fields.append(
SchemaField(
name=name,
@@ -252,15 +207,13 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
name=name,
type="object",
required=required,
description=key_description,
children=children,
)
)
continue
# --- Scalar and entity ref fields ---
type_str, value_description = _parse_type_and_description(str(value))
description = key_description or value_description
type_str, description = _parse_type_and_description(str(value))
is_entity_ref = _is_entity_ref_type(type_str)
fields.append(
-50
View File
@@ -63,10 +63,8 @@ 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"
@@ -80,59 +78,11 @@ 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."""
-1
View File
@@ -142,5 +142,4 @@ class SearchResponse(BaseModel):
results: List[SearchResult]
current_page: int
page_size: int
total: int = 0
has_more: bool = False
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Dict, List, Set
from pydantic import BaseModel, Field
# avoid circular imports
# avoid cirular imports
if TYPE_CHECKING: # pragma: no cover
from basic_memory.sync.sync_service import SyncReport
-2
View File
@@ -14,7 +14,6 @@ from basic_memory.schemas.v2.graph import (
GraphEdge,
GraphNode,
GraphResponse,
OrphanEntitiesResponse,
)
from basic_memory.schemas.v2.resource import (
CreateResourceRequest,
@@ -34,7 +33,6 @@ __all__ = [
"GraphEdge",
"GraphNode",
"GraphResponse",
"OrphanEntitiesResponse",
"CreateResourceRequest",
"UpdateResourceRequest",
"ResourceResponse",
-9
View File
@@ -29,12 +29,3 @@ class GraphResponse(BaseModel):
edges: list[GraphEdge] = Field(
default_factory=list, description="All resolved relations as edges"
)
class OrphanEntitiesResponse(BaseModel):
"""Entities that have no incoming or outgoing relations in the knowledge graph."""
entities: list[GraphNode] = Field(
default_factory=list, description="Entities with no relations"
)
total: int = Field(..., description="Total count of orphan entities")
+10 -10
View File
@@ -4,13 +4,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple, TYPE_CHECKING
from typing import List, Optional, Tuple, TYPE_CHECKING
from loguru import logger
from sqlalchemy import text
import logfire
from basic_memory import telemetry
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
@@ -111,7 +111,7 @@ class ContextService:
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
)
with logfire.span(
with telemetry.scope(
"memory.build_context",
domain="memory",
action="build_context",
@@ -122,7 +122,7 @@ class ContextService:
fetch_limit = limit + 1
normalized_path: Optional[str] = None
with logfire.span(
with telemetry.scope(
"memory.build_context.resolve_primary",
domain="memory",
action="build_context",
@@ -180,7 +180,7 @@ class ContextService:
type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
logger.debug(f"found primary type_id_pairs: {len(type_id_pairs)}")
with logfire.span(
with telemetry.scope(
"memory.build_context.find_related",
domain="memory",
action="build_context",
@@ -202,7 +202,7 @@ class ContextService:
observations_by_entity = {}
if include_observations and entity_ids:
with logfire.span(
with telemetry.scope(
"memory.build_context.load_observations",
domain="memory",
action="build_context",
@@ -226,7 +226,7 @@ class ContextService:
has_more=has_more,
)
with logfire.span(
with telemetry.scope(
"memory.build_context.shape_results",
domain="memory",
action="build_context",
@@ -307,7 +307,7 @@ class ContextService:
entity_id_values = ", ".join([str(i) for i in entity_ids])
# Parameters for bindings - include project_id for security filtering
params: dict[str, Any] = {
params = {
"max_depth": max_depth,
"max_results": max_results,
"project_id": self.search_repository.project_id,
@@ -322,9 +322,9 @@ class ContextService:
since_utc = (
since.astimezone(timezone.utc) if since.tzinfo else since
) # pragma: no cover
params["since_date"] = since_utc.replace(tzinfo=None) # pragma: no cover
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
else:
params["since_date"] = since.isoformat()
params["since_date"] = since.isoformat() # pyright: ignore
date_filter = "AND e.created_at >= :since_date"
relation_date_filter = "AND e_from.created_at >= :since_date"
timeframe_condition = "AND eg.relation_date >= :since_date"
File diff suppressed because it is too large Load Diff
+6 -12
View File
@@ -12,7 +12,7 @@ import aiofiles
import yaml
import logfire
from basic_memory import telemetry
from basic_memory import file_utils
if TYPE_CHECKING: # pragma: no cover
@@ -89,7 +89,7 @@ class FileService:
"""
logger.debug(f"Reading entity content, entity_id={entity.id}, permalink={entity.permalink}")
with logfire.span(
with telemetry.scope(
"file_service.read_content",
domain="file_service",
action="read_content",
@@ -191,7 +191,7 @@ class FileService:
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
with logfire.span(
with telemetry.scope(
"file_service.write",
domain="file_service",
action="write",
@@ -249,7 +249,7 @@ class FileService:
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
with logfire.span(
with telemetry.scope(
"file_service.read_content",
domain="file_service",
action="read_content",
@@ -273,9 +273,6 @@ class FileService:
logger.warning("File not found", operation="read_file_content", path=str(full_path))
raise
except Exception as e:
if isinstance(e, FileNotFoundError):
logger.warning("File not found", operation="read_file", path=str(full_path))
raise
logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}")
@@ -299,7 +296,7 @@ class FileService:
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
with logfire.span(
with telemetry.scope(
"file_service.read_content",
domain="file_service",
action="read_content",
@@ -342,7 +339,7 @@ class FileService:
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
with logfire.span(
with telemetry.scope(
"file_service.read",
domain="file_service",
action="read",
@@ -369,9 +366,6 @@ class FileService:
)
return content, checksum
except FileNotFoundError as e:
logger.warning("File not found", operation="read_file", path=str(full_path))
raise FileOperationError(f"Failed to read file: {e}") from e
except Exception as e:
logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}")
+3 -11
View File
@@ -99,23 +99,18 @@ async def initialize_file_sync(
)
project_repository = ProjectRepository(session_maker)
# Filter to constrained project if MCP server was started with --project.
# Applied to both the initial background sync and the watch service so that
# running multiple `basic-memory mcp --project X` processes does not produce
# duplicate watchers fighting over the same files.
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
# Initialize watch service
watch_service = WatchService(
app_config=app_config,
project_repository=project_repository,
quiet=quiet,
constrained_project=constrained_project,
)
# Get active projects
active_projects = await project_repository.get_active_projects()
# Filter to constrained project if MCP server was started with --project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
active_projects = [p for p in active_projects if p.name == constrained_project]
logger.info(f"Background sync constrained to project: {constrained_project}")
@@ -159,10 +154,7 @@ async def initialize_file_sync(
# Don't await the tasks - let them run in background while we continue
# Then start the watch service in the background
if constrained_project:
logger.info(f"Starting watch service constrained to project: {constrained_project}")
else:
logger.info("Starting watch service for all projects")
logger.info("Starting watch service for all projects")
# run the watch service
await watch_service.run()
+21 -70
View File
@@ -1,7 +1,7 @@
"""Service and helpers for resolving markdown links and permalink-like identifiers."""
"""Service for resolving markdown links to permalinks."""
import uuid as uuid_mod
from typing import Any, Optional, Tuple, Dict
from typing import Optional, Tuple, Dict
from loguru import logger
@@ -13,59 +13,10 @@ from basic_memory.repository.search_repository import create_search_repository
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
from basic_memory.utils import (
build_permalink_resolution_candidates,
build_canonical_permalink,
generate_permalink,
normalize_project_reference,
)
from basic_memory.workspace_context import current_workspace_permalink_context
def is_workspace_qualified_plain_identifier(identifier: str) -> bool:
"""Return True for plain ``<workspace>/<project>/<path>`` identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://"):
return False
normalized = normalize_project_reference(stripped).strip("/")
return len(normalized.split("/", 2)) == 3
async def detect_project_from_workspace_identifier_prefix(
identifier: str,
config: BasicMemoryConfig,
context: Any | None = None,
) -> Optional[str]:
"""Resolve a project route from a plain workspace-qualified identifier."""
if not is_workspace_qualified_plain_identifier(identifier):
return None
from basic_memory.mcp.project_context import (
_workspace_identifier_discovery_available,
resolve_workspace_qualified_identifier,
)
if not _workspace_identifier_discovery_available(identifier, config):
return None
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise
if workspace_resolution is None:
return None
return workspace_resolution.project_identifier
class LinkResolver:
@@ -238,25 +189,25 @@ class LinkResolver:
"""Resolve a link within a specific project scope."""
clean_text = link_text
include_project = self._include_project_permalinks()
workspace_context = current_workspace_permalink_context()
workspace_permalink = (
workspace_context.workspace_slug
if workspace_context and workspace_context.should_prefix_permalinks
else None
)
# Trigger: callers can pass title, short permalink, project/path, or
# workspace/project/path identifiers to the same resolver.
# Why: search results and memory:// URLs should stay usable across read,
# edit, delete, move, and API-level entity resolution.
# Outcome: resolve canonical workspace IDs and legacy project-prefixed rows
# through one shared candidate builder.
permalink_candidates = build_permalink_resolution_candidates(
clean_text,
project_permalink,
include_project=include_project,
workspace_permalink=workspace_permalink,
)
canonical_permalink: Optional[str] = None
legacy_permalink: Optional[str] = None
# Trigger: permalinks include project slug and project permalink is known
# Why: support globally addressable permalinks while keeping legacy links resolvable
# Outcome: include canonical and legacy candidates for resolution
if include_project and project_permalink:
canonical_permalink = build_canonical_permalink(
project_permalink, clean_text, include_project=True
)
if clean_text.startswith(f"{project_permalink}/"):
legacy_candidate = clean_text.removeprefix(f"{project_permalink}/")
if legacy_candidate:
legacy_permalink = legacy_candidate
permalink_candidates = []
for candidate in (clean_text, canonical_permalink, legacy_permalink):
if candidate and candidate not in permalink_candidates:
permalink_candidates.append(candidate)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)

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