Compare commits

..

4 Commits

Author SHA1 Message Date
Drew Cain 4d0566c983 fix(mcp): drop stale API version from diagnostics, suppress duplicated structured output
- Remove orphaned __api_version__ ('v0' since 2025; real API routes are /v2,
  and nothing else reads the constant)
- output_schema=None so FastMCP doesn't duplicate the markdown report into
  structuredContent (halves the payload)
- Add title/tags annotations so #963 and #968 are merge-order independent

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 14:56:35 -05:00
Drew Cain 8f444f9fe9 test(mcp): use exact-equality assertions for redacted URLs
CodeQL flags dotted-host substring checks against URLs
(py/incomplete-url-substring-sanitization); exact matches are also
stronger assertions.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 09:42:22 -05:00
Drew Cain cbc2d5feb1 fix(mcp): redact database_url credentials in diagnostics output
Extends the existing _SECRET_FIELDS redaction to also strip the
user:password userinfo component from any URL-bearing config fields
(database_url) before surfacing them in diagnostics output. Adds
_redact_url() helper and a _URL_FIELDS set so future credential-bearing
URL fields are easy to add. Postgres passwords no longer leak to MCP
clients or support transcripts; host/port/db-name remain visible.

Fixes the P1 security finding raised in review of PR #963.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 09:37:00 -05:00
Drew Cain 29be0299f0 feat(mcp): add basic_memory_diagnostics tool for version and system info
Adds a new read-only MCP tool `basic_memory_diagnostics` that surfaces:
- Basic Memory package version and API version (from __init__.py)
- Python version and platform/architecture details
- Config file path and full config.json contents (cloud_api_key redacted)

Resolves #187.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 01:10:21 -05:00
109 changed files with 808 additions and 4164 deletions
+23 -35
View File
@@ -28,10 +28,7 @@ You are an expert release manager for the Basic Memory project. When the user ru
#### Documentation Validation
1. **Changelog Check**
- CHANGELOG.md contains entry for target version **already landed on `main`**
(main only accepts changes via PR, so the changelog entry must go through
its own PR before running the release; the recipe pre-flight-checks for a
`## vX.Y.Z` heading)
- CHANGELOG.md contains entry for target version
- Entry includes all major features and fixes
- Breaking changes are documented
@@ -44,15 +41,11 @@ just release <version>
The justfile target handles:
- ✅ Version format validation
- ✅ Git status and branch checks
-Changelog entry check (must already be on `main`)
- ✅ Quality checks (`just lint` + `just typecheck`)
-Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
-Release PR: commits the bump on a `release/vX.Y.Z` branch, opens a PR
(`chore(core): release vX.Y.Z`), and rebase-merges it — the `main` ruleset
rejects direct pushes and the repo disallows merge commits
- ✅ Tags the rebased bump commit on `main` (found by commit subject, since
the rebase rewrites the SHA) and pushes the tag
-Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Release workflow trigger (automatic on tag push)
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
@@ -96,7 +89,7 @@ After PyPI release is published, update the MCP registry:
2. **Publish to MCP Registry**
```bash
# from the basic-memory repo root
cd /Users/drew/code/basic-memory
mcp-publisher publish
```
@@ -116,7 +109,7 @@ After PyPI release is published, update the MCP registry:
#### Website Updates
**1. basicmachines.co** (sibling `basicmachines.co` repo)
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
- **Goal**: Update version number displayed on the homepage
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
- **What to update**:
@@ -133,31 +126,26 @@ After PyPI release is published, update the MCP registry:
7. Push branch: `git push origin release/v{VERSION}`
- **Deploy**: Follow deployment process for basicmachines.co
**2. docs.basicmemory.com** (sibling `docs.basicmemory.com` repo)
- **Goal**: Add a What's New page for the release and bump the homepage badge
- **Site shape**: Nuxt/Docus content site. The changelog page
(`content/2.whats-new/*.changelog.md`) auto-fetches GitHub releases — no
manual changelog update needed. See that repo's CLAUDE.md "Version Bump
Checklist".
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
- **Goal**: Add new release notes section to the latest-releases page
- **File**: `src/pages/latest-releases.mdx`
- **What to do**:
1. Pull latest from GitHub: `git pull origin main`
2. Create release branch: `git checkout -b release/v{VERSION}`
3. Read `CHANGELOG.md` in the `basic-memory` repo to get release content
4. **New minor/major release**: add `content/2.whats-new/1.v{VERSION}.md`
modeled on the previous version page (frontmatter title/description,
headline feature first, then sections, then an Upgrading note) and
renumber the existing what's-new pages down one slot (URLs don't
change — Nuxt strips the numeric prefixes)
5. **Patch release**: append a short note to the current version's page
instead of creating a new one
6. Update the homepage version badge in `content/index.md` (the
`v0.XX →` button text and its `to: /whats-new/v{VERSION}` link)
7. If the release adds user-facing features, update the relevant guide
and reference pages (`content/3.cloud/`, `content/9.reference/`)
8. Commit: `git commit -s -m "docs: add v{VERSION} release notes"`
9. Push branch and open a PR; merge after the release is tagged
- **Deploy**: push to main auto-deploys to development; production requires
manual workflow dispatch via GitHub Actions
3. Read the existing file to understand the format and structure
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
5. Add new release section **at the top** (after MDX imports, before other releases)
6. Follow the existing pattern:
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
- Focus statement if applicable
- `<Info>` block with highlights (3-5 key items)
- Sections for Features, Bug Fixes, Breaking Changes, etc.
- Link to full changelog at the end
- Separator `---` between releases
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
8. Push branch: `git push origin release/v{VERSION}`
- **Source content**: Extract and format sections from CHANGELOG.md for this version
- **Deploy**: Follow deployment process for docs.basicmemory.com
**4. Announce Release**
- Post to Discord community if significant changes
+3 -5
View File
@@ -300,9 +300,7 @@ 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, land the bump on `main` through a release PR, tag, and push the tag. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
**Main requires PRs.** The `main` ruleset rejects direct pushes ("Changes must be made through a pull request") and the repo disallows merge commits, so the recipes push a `release/vX.Y.Z` branch, open a PR titled `chore(core): release vX.Y.Z`, rebase-merge it with `gh pr merge --rebase`, then tag the rebased bump commit on `main` (located by its commit subject, since rebasing rewrites the SHA) and push the tag. The CHANGELOG entry for the version must already be on `main` — land it via a normal PR before running the recipe (it pre-flight-checks for a `## vX.Y.Z` heading).
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:**
@@ -310,7 +308,7 @@ Releases are driven by `just release` / `just beta` — never by a bare `git tag
just release v0.21.3
```
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It commits as `chore: update version to X.Y.Z for vX.Y.Z release` on a `release/vX.Y.Z` branch, lands it on `main` via a rebase-merged PR, then tags the rebased commit and pushes the tag. After the tag lands, the `Release` workflow builds the Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It 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 Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, 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`.
@@ -321,7 +319,7 @@ The recipe runs `just lint` + `just typecheck`, then updates every release manif
**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 a What's New page under `content/2.whats-new/` and bump the version badge in `content/index.md` (the changelog page auto-fetches GitHub releases; see that repo's CLAUDE.md version-bump checklist)
- `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
+1 -1
View File
@@ -590,7 +590,7 @@ retention).
| `BASIC_MEMORY_IMPORT_UPLOAD_MAX_BYTES` | `104857600` | Max uploaded import size |
```bash
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory reindex
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
tail -f ~/.basic-memory/basic-memory.log
```
+3 -3
View File
@@ -111,7 +111,7 @@ You can run Basic Memory CLI commands inside the container using `docker exec`:
docker exec basic-memory-server basic-memory status
# Sync files
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
# Show help
docker exec basic-memory-server basic-memory --help
@@ -137,7 +137,7 @@ When using Docker volumes, you'll need to configure projects to point to your mo
3. **Sync the new project:**
```bash
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
```
### Example: Setting up an Obsidian Vault
@@ -157,7 +157,7 @@ docker exec basic-memory-server basic-memory project create obsidian /app/data
docker exec basic-memory-server basic-memory project set-default obsidian
# Sync to index all files
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
```
### Environment Variables
+2 -2
View File
@@ -184,8 +184,8 @@ finance/ (lowercase f)
Use Basic Memory's built-in conflict detection:
```bash
# Index local file changes (conflicts are handled during the scan)
basic-memory reindex
# Sync will report conflicts
basic-memory sync
# Check sync status for warnings
basic-memory status
-183
View File
@@ -1,183 +0,0 @@
# Manual Pages
Basic Memory's manual is written in the style of Unix man pages — and
implemented as Basic Memory notes ([#952](https://github.com/basicmachines-co/basic-memory/issues/952)).
Every page is a markdown note conforming to the `Manpage` schema, `SEE ALSO`
entries are real knowledge-graph relations, and every example on every page
was executed against a live project before the page shipped. The manual
documents the tools; the tools verify the manual.
## Where it lives
The canonical manual is the **`manual` project in the Basic Memory team
workspace** (cloud, shared). Anyone can build their own: the schema ships as
an opt-in seed at `plugins/claude-code/schemas/manpage.md` — copy it into any
project's folder and start writing pages against it.
Layout:
```
manual/
├── schemas/Manpage.md # the manpage schema (type: schema)
├── man1/ # CLI commands bm(1), bm-status(1), ...
├── man3/ # MCP tools write-note(3), search-notes(3), ...
├── man5/ # file formats bm-note(5), bm-observation(5), ...
├── man7/ # concepts basic-memory(7), semantic-memory(7), ...
├── playground/ # scratch notes for destructive examples
└── diagrams/ # canvas visualizations of the manual graph
```
### Why "man1", "man3", "man5"?
The folder names are Unix's, unchanged since 1971. The manual is divided
into numbered **sections**, pages physically live in directories named
after them (`/usr/share/man/man1`, `man5`, ...), and the number tells you
what *kind* of thing is documented — not importance, not reading order:
- **1** — user commands (`ls`, `grep`)
- **2** — system calls
- **3** — library functions / APIs (`printf(3)`)
- **4** — devices
- **5** — file formats and config files (`crontab(5)`, `passwd(5)`)
- **6** — games (really)
- **7** — miscellanea: concepts, conventions, overviews (`regex(7)`, `signal(7)`)
- **8** — system administration
That's also why man page names carry the parenthesized number —
`crontab(1)` is the command, `crontab(5)` is the file format, same name in
two sections. `man 5 crontab` picks the section explicitly.
This manual copies that layout with the sections that have a Basic Memory
analog:
- **man1/** — `bm` CLI commands → `bm-status(1)`
- **man3/** — MCP tools, our equivalent of the "library API" section → `write-note(3)`
- **man5/** — file formats: note syntax, observations, relations, schemas → `bm-note(5)`
- **man7/** — concepts → `basic-memory(7)`, `semantic-memory(7)`
- **8** is reserved for admin/cloud operations but has no pages yet; 2, 4,
and 6 have no analog (no system calls, no devices, and no games — yet)
When a page says `see_also [[bm-note(5)]]`, the `(5)` reads "the
file-format page," exactly the way a Unix manual cross-references — except
here it's a traversable relation in the graph instead of a typographic
convention. The manual explains its own conventions in `man-pages(7)`
fittingly, the same page name Linux uses for this, and that almost nobody
ever reads.
## Page anatomy
Pages use the classic headers where applicable: `NAME`, `SYNOPSIS`,
`DESCRIPTION`, `PARAMETERS`, `MCP USAGE`, `CLI EQUIVALENT`, `EXAMPLES`,
`GOTCHAS`, `SEE ALSO`. Frontmatter (validated by the schema):
```yaml
type: manpage
section: 3 # 1 | 3 | 5 | 7 | 8
name: write-note # page name without section suffix
summary: create or overwrite a markdown note in the knowledge base
generated: hand # hand | registry | typer (regeneration ownership)
tool: write_note # section-3 pages: the MCP tool documented
command: basic-memory status # section-1 pages: the CLI command documented
verified: 0.21.6 mcp+cli # version + path(s) that proved the page
```
Field knowledge accumulates as observations — `[gotcha]`, `[bug]` (with issue
links), `[pattern]` — and `SEE ALSO` entries are `see_also` relations, so the
manual is a navigable graph, not a folder of files.
## How to use it
Man-style reads (any MCP client or the CLI):
```bash
# read a page
bm tool read-note "man3/write-note-3" --project manual
# apropos — find pages by section, tool, or text
bm tool search-notes --project manual # then filter, or via MCP:
# search_notes(project="manual", metadata_filters={"type": "manpage", "section": 3})
# search_notes(project="manual", metadata_filters={"type": "manpage", "tool": "write_note"})
# traverse SEE ALSO from any page
# build_context(url="man3/write-note-3", project="manual")
```
A future `bm man <topic>` command is thin sugar over exactly these calls.
And for the real thing — `man bm` in an actual terminal:
```bash
bm man install # copies bundled groff pages to ~/.local/share/man
man bm # the overview page, rendered by man(1)
man basic-memory # same page via its alias
```
`bm man install` warns with a one-line `MANPATH` fix if the install root
isn't searched by your `man`. Agents with shell access can use `man bm` as
an offline quick reference; the full per-tool detail stays in the manual
project's section-3 pages.
## The verification discipline
Two rules make the manual trustworthy:
1. **Examples must have run.** An `EXAMPLES` (or `MCP USAGE` / `CLI
EQUIVALENT`) block contains only commands that actually executed against
the manual project. Destructive operations (`delete_note`, `move_note`,
destructive `edit_note`) run only against `playground/` notes — never
against pages. The `verified:` field records the version and which path
proved the page: `mcp` (live service), `cli` (dev checkout), or both.
2. **The schema is the linter.** Validate the whole manual any time:
```bash
bm tool schema-validate manpage --project manual
# → {"total_notes": 38, "valid_count": 38, "warning_count": 0, ...}
```
`bm orphans --project manual` confirms every page is connected to the
graph, and `schema_diff`/`schema_infer` report drift between the schema
and how pages are actually written.
Because verification exercises real tool calls against the live service,
building the manual doubles as an end-to-end smoke test. The initial build
found six bugs in one pass (#954#959) — including the verification rule
catching a test that asserted a bug as expected output (#958).
## Adding or updating a page
1. Run the commands you intend to document; keep the actual output.
2. Write the page with `write_note`, passing frontmatter through the
`metadata` parameter (nested YAML in content frontmatter is unreliable on
some clients):
```
write_note(title="my-tool(3)", directory="man3", project="manual",
note_type="manpage",
metadata={"section": 3, "name": "my-tool",
"summary": "...", "generated": "hand",
"tool": "my_tool", "verified": "<version> mcp"})
```
3. Link related pages in `SEE ALSO` with `see_also [[other-page(3)]]`.
Forward references to pages that don't exist yet are fine — they resolve
automatically when the target is written.
4. Validate: `bm tool schema-validate manpage --project manual`.
For mechanical updates to generated sections, prefer `edit_note` with
`replace_section` / `insert_after_section` so curated content (EXAMPLES,
GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the
`generated:` field declares.
## Roadmap
- **Registry generator** — section-3 SYNOPSIS/PARAMETERS generated from the
MCP tool registry (docstrings + pydantic schemas), section-1 from Typer
help; the hand-written corpus is the template spec. Regenerate-and-diff in
CI becomes the drift gate.
- **`bm man <topic>`** — CLI sugar over `read_note` + metadata search.
(`bm man install` + a hand-written `bm.1` already ship — the first slice
of [#610](https://github.com/basicmachines-co/basic-memory/issues/610);
the generator will produce per-command pages from the same extraction.)
- **Docs site** — the notes remain canonical for sections 5 and 7, code is
canonical for 1 and 3; both render to the hosted docs site.
-70
View File
@@ -107,76 +107,6 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for indexed document/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for search queries. |
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
| `search_entity_boost_enabled` | `BASIC_MEMORY_SEARCH_ENTITY_BOOST_ENABLED` | `false` | Enable the entity-aware ranking boost in hybrid search (see below). Default off: benchmark-validated as inert on LoCoMo and prone to Title-Case false positives. |
| `search_entity_boost_weight` | `BASIC_MEMORY_SEARCH_ENTITY_BOOST_WEIGHT` | `0.15` | Per-matched-term multiplier strength for the entity boost. A candidate matching N query entity terms is scaled by `1 + weight * min(N, max_terms)`. |
| `search_entity_boost_max_terms` | `BASIC_MEMORY_SEARCH_ENTITY_BOOST_MAX_TERMS` | `3` | Maximum number of distinct matched entity terms that contribute to the boost, bounding the multiplier. |
## Entity-Aware Ranking Boost
Hybrid search fuses keyword (FTS) and vector similarity, but proper nouns in a query
carry no special weight against generic semantic similarity. As a result, a document
about a *different* entity on the same topic can outrank the document that actually
names the queried entity — e.g. "What are Joanna's hobbies?" surfacing a generic
hobbies note ahead of Joanna's note (see
[#951](https://github.com/basicmachines-co/basic-memory/issues/951)).
When `search_entity_boost_enabled=true`, hybrid retrieval performs a final,
lexical-only re-scoring pass:
1. It extracts candidate entity terms from the query — capitalized / proper-noun
tokens that are not common stopwords (e.g. `Joanna`, `Anthony`, `NASA`).
2. For each fused candidate, it counts how many distinct query entity terms appear in
the candidate's entity name (its title) or in a relation row's linked entity names.
3. Matching candidates have their fused score multiplied by
`1 + weight * min(matches, max_terms)`, so an entity-matching document can be
promoted above a higher-similarity non-matching one.
The boost adds **no model inference** — it is pure index/lexical lookup, so per-query
latency overhead is trivial. It only affects `hybrid` retrieval; `text` and `vector`
modes are unchanged. Non-matching candidates keep their original scores, so ordering
among them is preserved.
```bash
export BASIC_MEMORY_SEARCH_ENTITY_BOOST_ENABLED=true
# Optional tuning:
export BASIC_MEMORY_SEARCH_ENTITY_BOOST_WEIGHT=0.15
export BASIC_MEMORY_SEARCH_ENTITY_BOOST_MAX_TERMS=3
```
> **Default off.** This setting is disabled by default. See the benchmark
> findings below for why the default stays off and where the boost helps.
### Benchmark findings
The boost was benchmarked against LoCoMo (the
[basic-memory-benchmarks](https://github.com/basicmachines-co/basic-memory-benchmarks)
retrieval suite, hybrid mode) and a hand-built adversarial corpus. Two results
drove the decision to keep the default **off** and leave the weight at `0.15`:
1. **LoCoMo is insensitive to the boost.** Sweeping the weight across
`0.15, 0.3, 0.5, 1.0, 2.0` produced *identical* recall@5, recall@10, MRR, and
content-hit at every point — no query reordered, no score changed. LoCoMo's
documents are titled by conversation/session id and expose speaker names only
in body text, never as entity titles or relation names. Because the boost
matches query proper nouns against a candidate's **title or linked relation
names**, it never fires on this corpus. LoCoMo therefore provides no signal to
raise the weight, and the boost neither helps nor harms it.
2. **A capitalization-only heuristic has false positives.** On a corpus where
entity terms appear in titles, the boost correctly promotes the right document
for clean proper nouns (e.g. `Katze`) and is correctly inert on
lowercase-leading identifiers (e.g. `getUserById`, ignored). But **Title-Case
queries can regress**: a query like `What Is The Plan For Q3` extracts `Q3` as
an entity term, and even at weight `0.15` it promotes a document that
*literally* contains "Q3" above the more relevant document that says "third
quarter". Since entity detection is lexical (capitalization, no NER), any
capitalized non-entity token in a query is a potential false positive.
**Guidance.** Enable the boost only on entity-heavy corpora where your queries
name entities that are themselves note titles or linked relations (the #951
"Joanna" case). Prefer natural-case queries (`What are Joanna's hobbies?`) over
Title-Cased phrasing, which can inject spurious entity terms. Leave it off for
conversational / body-text-keyed corpora like LoCoMo, where it cannot help.
## Embedding Providers
+24 -114
View File
@@ -383,31 +383,17 @@ release version:
echo "❌ Tag {{version}} already exists"
exit 1
fi
# Changelog must already be on main (land it via a normal PR first)
if ! grep -q "^## {{version}} " CHANGELOG.md; then
echo "❌ CHANGELOG.md has no entry for {{version}}. Land one via PR first."
exit 1
fi
# Run quality checks
echo "🔍 Running lint checks..."
just lint
just typecheck
# Update all package manifests to the one Basic Memory product version.
echo "📝 Updating consolidated package versions..."
just set-version "{{version}}"
# Trigger: main's ruleset rejects direct pushes ("Changes must be made
# through a pull request").
# Why: the version bump must land on main before the tag is cut, so it
# rides a release PR that is rebase-merged (the repo disallows merge
# commits).
# Outcome: the bump commit gets a new SHA on main; the tag is created on
# that rebased commit, found by its commit subject.
COMMIT_SUBJECT="chore: update version to $VERSION_NUM for {{version}} release"
git checkout -b "release/{{version}}"
# Commit version update
git add \
src/basic_memory/__init__.py \
server.json \
@@ -418,56 +404,22 @@ release version:
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
git commit -s -m "$COMMIT_SUBJECT"
echo "📤 Opening release PR..."
git push -u origin "release/{{version}}"
gh pr create --title "chore(core): release {{version}}" \
--body "Version bump for {{version}}. See CHANGELOG.md for release notes."
# Trigger: the PR may not be mergeable synchronously (merge gates,
# required checks added later, or GitHub still computing mergeability).
# Why: the tag must point at the bump commit on main, so the recipe
# cannot tag until the merge has actually landed.
# Outcome: try a direct rebase-merge, fall back to queueing auto-merge,
# then poll main for the rebased bump commit before tagging.
if ! gh pr merge "release/{{version}}" --rebase --delete-branch; then
echo "⚠️ Direct merge did not complete (merge gates pending?). Queueing auto-merge..."
gh pr merge "release/{{version}}" --rebase --delete-branch --auto
fi
echo "⏳ Waiting for the bump commit to land on main..."
TAG_COMMIT=""
for _ in $(seq 1 60); do
git fetch origin main --quiet
TAG_COMMIT=$(git log FETCH_HEAD --fixed-strings --grep "$COMMIT_SUBJECT" --format='%H' -1)
[[ -n "$TAG_COMMIT" ]] && break
sleep 5
done
if [[ -z "$TAG_COMMIT" ]]; then
echo "❌ Bump commit not on main after 5 minutes (merge still pending?)."
echo " Once the release PR merges, finish the release manually:"
echo " git fetch origin main"
echo " git tag {{version}} \$(git log FETCH_HEAD --fixed-strings --grep \"$COMMIT_SUBJECT\" --format='%H' -1)"
echo " git push origin {{version}}"
exit 1
fi
git checkout main
git pull --ff-only origin main
git branch -D "release/{{version}}" 2>/dev/null || true
echo "🏷️ Creating tag {{version}} at $TAG_COMMIT..."
git tag "{{version}}" "$TAG_COMMIT"
git commit -s -m "chore: update version to $VERSION_NUM for {{version}} release"
# Create and push tag
echo "🏷️ Creating tag {{version}}..."
git tag "{{version}}"
echo "📤 Pushing to GitHub..."
git push origin main
git push origin "{{version}}"
echo "✅ Release {{version}} created successfully!"
echo "📦 GitHub Actions will build and publish to PyPI"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo ""
echo "📝 REMINDER: Post-release tasks:"
echo " 1. docs.basicmemory.com - Add a What's New page under content/2.whats-new/"
echo " and bump the badge in content/index.md (see that repo's CLAUDE.md)"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " 3. MCP Registry - Run: mcp-publisher publish"
echo " See: .claude/commands/release/release.md for detailed instructions"
@@ -515,15 +467,7 @@ beta version:
echo "📝 Updating consolidated package versions..."
just set-version "{{version}}"
# Trigger: main's ruleset rejects direct pushes ("Changes must be made
# through a pull request").
# Why: the version bump must land on main before the tag is cut, so it
# rides a release PR that is rebase-merged (the repo disallows merge
# commits).
# Outcome: the bump commit gets a new SHA on main; the tag is created on
# that rebased commit, found by its commit subject.
COMMIT_SUBJECT="chore: update version to $VERSION_NUM for {{version}} beta release"
git checkout -b "release/{{version}}"
# Commit version update
git add \
src/basic_memory/__init__.py \
server.json \
@@ -534,57 +478,23 @@ beta version:
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
git commit -s -m "$COMMIT_SUBJECT"
echo "📤 Opening release PR..."
git push -u origin "release/{{version}}"
gh pr create --title "chore(core): release {{version}}" \
--body "Version bump for {{version}} beta."
# Trigger: the PR may not be mergeable synchronously (merge gates,
# required checks added later, or GitHub still computing mergeability).
# Why: the tag must point at the bump commit on main, so the recipe
# cannot tag until the merge has actually landed.
# Outcome: try a direct rebase-merge, fall back to queueing auto-merge,
# then poll main for the rebased bump commit before tagging.
if ! gh pr merge "release/{{version}}" --rebase --delete-branch; then
echo "⚠️ Direct merge did not complete (merge gates pending?). Queueing auto-merge..."
gh pr merge "release/{{version}}" --rebase --delete-branch --auto
fi
echo "⏳ Waiting for the bump commit to land on main..."
TAG_COMMIT=""
for _ in $(seq 1 60); do
git fetch origin main --quiet
TAG_COMMIT=$(git log FETCH_HEAD --fixed-strings --grep "$COMMIT_SUBJECT" --format='%H' -1)
[[ -n "$TAG_COMMIT" ]] && break
sleep 5
done
if [[ -z "$TAG_COMMIT" ]]; then
echo "❌ Bump commit not on main after 5 minutes (merge still pending?)."
echo " Once the release PR merges, finish the release manually:"
echo " git fetch origin main"
echo " git tag {{version}} \$(git log FETCH_HEAD --fixed-strings --grep \"$COMMIT_SUBJECT\" --format='%H' -1)"
echo " git push origin {{version}}"
exit 1
fi
git checkout main
git pull --ff-only origin main
git branch -D "release/{{version}}" 2>/dev/null || true
echo "🏷️ Creating tag {{version}} at $TAG_COMMIT..."
git tag "{{version}}" "$TAG_COMMIT"
git commit -s -m "chore: update version to $VERSION_NUM for {{version}} beta release"
# Create and push tag
echo "🏷️ Creating tag {{version}}..."
git tag "{{version}}"
echo "📤 Pushing to GitHub..."
git push origin main
git push origin "{{version}}"
echo "✅ Beta release {{version}} created successfully!"
echo "📦 GitHub Actions will build and publish to PyPI as pre-release"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo "📥 Install with: uv tool install basic-memory --pre"
echo ""
echo "📝 REMINDER: For stable releases, update documentation sites:"
echo " 1. docs.basicmemory.com - Add a What's New page under content/2.whats-new/"
echo " and bump the badge in content/index.md (see that repo's CLAUDE.md)"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " See: .claude/commands/release/release.md for detailed instructions"
-55
View File
@@ -1,55 +0,0 @@
---
title: Manpage
type: schema
entity: Manpage
version: 1
schema:
gotcha?(array): string, sharp edges and surprising behavior learned from live verification
example?(array): string, worked examples beyond the generated synopsis
pattern?(array): string, recommended idioms and usage patterns
bug?(array): string, known defects affecting this surface, with issue links
see_also?(array): Entity, related manual pages — the SEE ALSO graph
settings:
validation: warn
frontmatter:
section(enum, Unix manual section number): [1, 3, 5, 7, 8]
name: string, page name without section suffix (e.g. write-note)
summary: string, one-line NAME description
generated?(enum, who owns the mechanical sections): [registry, typer, hand]
tool?: string, MCP tool this page documents (section 3 pages)
command?: string, CLI command this page documents (section 1 pages)
verified?: string, version and path that verified this page (e.g. 0.21.6 mcp+cli)
since?: string, version this surface first appeared
---
# Manpage
A **ManpageNote** is one page of a Unix-style manual implemented as Basic
Memory notes (issue #952): commands in section 1, MCP tools in section 3,
file formats in section 5, concepts in section 7, admin in section 8. The
manual becomes a knowledge graph — `SEE ALSO` entries are typed relations,
and pages are found by structured recall:
`search_notes(metadata_filters={"type": "manpage", "section": 3})`.
This schema is an opt-in seed for documentation projects; the canonical
manual lives in the Basic Memory team workspace `manual` project.
## What makes a good ManpageNote
- **NAME / SYNOPSIS / DESCRIPTION** — classic man-page structure, with
PARAMETERS, MCP USAGE, CLI EQUIVALENT, EXAMPLES, GOTCHAS, SEE ALSO where
applicable.
- **Verified examples** — EXAMPLES contain only commands that actually ran;
the `verified` field records the version and path (mcp, cli, or both).
- **generated** — declares regeneration ownership: `registry` (from the MCP
tool registry) and `typer` (from CLI help) pages get mechanical sections
rewritten; curated sections (EXAMPLES, GOTCHAS, SEE ALSO, observations)
are never overwritten.
- **gotcha / bug observations** — field knowledge accumulates on pages
without being clobbered by regeneration; bugs link their tracking issues.
## Frontmatter
`type: manpage` plus `section` makes the manual queryable like `man -k`:
by section, by `tool`, by `command`, or by missing/stale `verified` stamps.
Validation is `warn`, never blocking.
-3
View File
@@ -2,6 +2,3 @@
# Package version - updated by release automation
__version__ = "0.22.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -193,7 +193,7 @@ async def add_project(
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
message=f"Project '{new_project.name}' added successfully",
status="success",
default=new_project.is_default or False,
default=project_data.set_default,
new_project=ProjectItem(
id=new_project.id,
external_id=new_project.external_id,
@@ -320,20 +320,7 @@ async def resolve_project_identifier(
)
if not project:
detail = f"Project not found: '{data.identifier}'"
# Trigger: resolution missed and the projects table is empty.
# Why: a fresh install bootstraps config.json's default project before any
# reconciliation has created database rows (the one-shot CLI never runs
# the server lifespan), so the first read fails on the configured
# default and the bare not-found message reads as a broken install
# rather than a missing first-run step (#974 follow-up).
# Outcome: the error names the setup command instead.
if not await project_repository.find_all(limit=1, use_load_options=False):
detail = (
f"{detail}. No projects are set up yet — run "
"'basic-memory project add <name> <path>' to create one."
)
raise HTTPException(status_code=404, detail=detail)
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
return ProjectResolveResponse(
external_id=project.external_id,
@@ -572,10 +559,12 @@ async def set_default_project_by_id(
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
try:
# Get the old default project from database. It may be absent during
# bootstrap/recovery (no default row yet); that is a valid state, not an
# error, so we only echo it back when one exists.
# Get the old default project from database
default_project = await project_repository.get_default_project()
if not default_project:
raise HTTPException( # pragma: no cover
status_code=404, detail="No default project is currently set"
)
# Get the new default project by external_id
new_default_project = await project_repository.get_by_external_id(project_id)
@@ -587,27 +576,17 @@ async def set_default_project_by_id(
# Set as default using project name (service layer still uses names internally)
await project_service.set_default_project(new_default_project.name)
# Trigger: a previous default existed
# Why: ProjectStatusResponse.old_project is Optional; the no-default
# bootstrap case must succeed with old_project=None
# Outcome: response echoes the prior default only when there was one
old_project = (
ProjectItem(
return ProjectStatusResponse(
message=f"Project '{new_default_project.name}' set as default successfully",
status="success",
default=True,
old_project=ProjectItem(
id=default_project.id,
external_id=default_project.external_id,
name=default_project.name,
path=default_project.path,
is_default=False,
)
if default_project
else None
)
return ProjectStatusResponse(
message=f"Project '{new_default_project.name}' set as default successfully",
status="success",
default=True,
old_project=old_project,
),
new_project=ProjectItem(
id=new_default_project.id,
external_id=new_default_project.external_id,
-1
View File
@@ -147,7 +147,6 @@ async def to_graph_context(
from_entity_id=item.from_id,
from_entity_external_id=from_ext_id,
to_entity=to_title,
to_name=item.to_name,
to_entity_id=item.to_id,
to_entity_external_id=to_ext_id,
created_at=item.created_at,
-3
View File
@@ -83,11 +83,8 @@ def app_callback(
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
# Skip for 'reset' command - it manages its own database lifecycle
# Skip for 'man' - it only copies packaged files; a broken local database
# must not block installing the offline docs
skip_init_commands = {
"doctor",
"man",
"mcp",
"status",
"sync",
-15
View File
@@ -168,20 +168,6 @@ def _manual_update_hint(source: InstallSource) -> str:
)
def _preload_lazy_console_modules() -> None:
"""Import modules the post-upgrade output path defers until print time.
Trigger: an in-place upgrade is about to replace this installation on disk.
Why: rich and typer defer some imports until print/excepthook time; once
`brew upgrade` / `uv tool upgrade` removes the running version's files,
those imports raise ModuleNotFoundError and the final status message
crashes the exiting process.
Outcome: import the deferred modules now, while the files still exist.
"""
import rich._emoji_codes # noqa: F401
import typer.rich_utils # noqa: F401
def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
"""Persist the timestamp for the most recent attempted update check."""
config = config_manager.load_config()
@@ -302,7 +288,6 @@ def run_auto_update(
else BREW_UPGRADE_TIMEOUT_SECONDS
)
_preload_lazy_console_modules()
install_result = _run_subprocess(
command,
timeout_seconds=timeout,
@@ -4,7 +4,6 @@ from . import ci, status, db, doctor, import_memory_json, mcp, import_claude_con
from . import (
import_claude_projects,
import_chatgpt,
man,
tool,
project,
format,
@@ -30,5 +29,4 @@ __all__ = [
"schema",
"update",
"workspace",
"man",
]
+2 -12
View File
@@ -34,10 +34,8 @@ from basic_memory.ci.project_updates import (
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
# MCP tool functions are imported inside the async helpers below: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
console = Console()
@@ -254,10 +252,6 @@ async def seed_project_update_schemas(
refresh: bool = False,
) -> list[str]:
"""Seed Auto BM schema notes without overwriting customized schemas."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
seeded: list[str] = []
routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace)
for spec in schema_seed_specs():
@@ -301,10 +295,6 @@ async def publish_project_update_note(
note: ProjectUpdateNote,
) -> dict[str, Any]:
"""Search by idempotency key and then upsert the deterministic note path."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
routed_project = _routed_project(
project=config.project,
project_id=config.project_id,
@@ -3,10 +3,12 @@
import asyncio
from typing import Optional, TypeVar, Coroutine, Any
from mcp.server.fastmcp.exceptions import ToolError
import typer
from rich.console import Console
from basic_memory import db
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
@@ -29,9 +31,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
Returns:
The result of the coroutine
"""
# Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load
# at CLI import time — only when a command actually runs (#886).
from basic_memory import db
async def _with_cleanup() -> T:
try:
@@ -54,8 +53,6 @@ async def run_sync(
force_full: If True, force a full scan bypassing watermark optimization
run_in_background: If True, return immediately; if False, wait for completion
"""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
@@ -89,9 +86,6 @@ async def run_sync(
async def get_project_info(project: str):
"""Get project information via API endpoint."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
+7 -28
View File
@@ -1,27 +1,24 @@
"""Database management commands."""
# PEP 563 lazy annotations let signatures reference IndexProgress without importing
# the indexing stack at module load; reset/reindex import their heavy database and
# sync dependencies at call time so CLI startup stays fast (#886).
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING
import psutil
import typer
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from sqlalchemy.exc import OperationalError
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, ProjectMode
if TYPE_CHECKING:
from basic_memory.indexing import IndexProgress
from basic_memory.indexing import IndexProgress
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
console = Console()
@@ -162,13 +159,6 @@ async def _reindex_projects(app_config):
This ensures all database operations use the same event loop,
and proper cleanup happens when the function completes.
"""
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
try:
await reconcile_projects_with_config(app_config)
@@ -207,12 +197,6 @@ def reset(
),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
# Deferred: SQLAlchemy and the db module load only when a reset actually
# runs, not on every CLI start (#886).
from sqlalchemy.exc import OperationalError
from basic_memory import db
console.print(
"[yellow]Note:[/yellow] This only deletes the index database. "
"Your markdown note files will not be affected.\n"
@@ -336,15 +320,10 @@ async def _reindex(
project: str | None,
):
"""Run reindex operations."""
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import EntityRepository, ProjectRepository
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.services.search_service import SearchService
from basic_memory.services.file_service import FileService
from basic_memory.sync.sync_service import get_sync_service
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.entity_parser import EntityParser
+4 -9
View File
@@ -7,12 +7,16 @@ import uuid
from pathlib import Path
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
import typer
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
from basic_memory.schemas.base import Entity
@@ -25,12 +29,6 @@ console = Console()
async def run_doctor() -> None:
"""Run local consistency checks for file <-> database flows."""
# Deferred: the markdown parsing stack is only needed while the checks run,
# and importing it at module level slows every CLI invocation (#886).
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
@@ -142,9 +140,6 @@ def doctor(
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Run local consistency checks to verify file/database sync."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
# Doctor runs local filesystem checks — always default to local routing
@@ -1,34 +1,25 @@
"""Import command for ChatGPT conversations."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -69,9 +60,6 @@ def import_chatgpt(
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
# Create importer and run import
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers import ChatGPTImporter
importer = ChatGPTImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -66,9 +57,6 @@ def import_claude(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
importer = ClaudeConversationsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import project data from Claude.ai."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -65,9 +56,6 @@ def import_projects(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
importer = ClaudeProjectsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import from JSON memory format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -64,9 +55,6 @@ def memory_json(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
importer = MemoryJsonImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
-76
View File
@@ -1,76 +0,0 @@
"""Install the bundled man pages so `man bm` works."""
import shutil
import subprocess
from pathlib import Path
from typing import Annotated, Optional
import typer
from rich.console import Console
from basic_memory.cli.app import app
console = Console()
man_app = typer.Typer(help="Manage the bm man pages.")
app.add_typer(man_app, name="man")
# Bundled groff sources ship inside the package (src/basic_memory/man).
_MAN_SOURCE_DIR = Path(__file__).parent.parent.parent / "man"
def _default_man_root() -> Path:
# Why ~/.local/share/man: manpath(1) derives man directories from PATH
# entries on both man-db (Linux) and BSD man (macOS), so ~/.local/bin on
# PATH — the pipx/uv tool layout — makes this root searchable without any
# MANPATH configuration.
return Path.home() / ".local" / "share" / "man"
def _man_root_on_manpath(man_root: Path) -> Optional[bool]:
"""Best-effort check whether man(1) will search man_root; None if unknown."""
try:
result = subprocess.run(["manpath"], capture_output=True, text=True, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
paths = [entry.rstrip("/") for entry in result.stdout.strip().split(":") if entry]
return str(man_root).rstrip("/") in paths
@man_app.command()
def install(
directory: Annotated[
Optional[Path],
typer.Option(
"--dir",
help="Man root to install into (default: ~/.local/share/man)",
),
] = None,
) -> None:
"""Install the bm man pages, then try `man bm`."""
man_root = (directory or _default_man_root()).expanduser()
man1 = man_root / "man1"
man1.mkdir(parents=True, exist_ok=True)
pages = sorted(_MAN_SOURCE_DIR.glob("*.1"))
if not pages: # pragma: no cover - broken packaging, not a runtime state
console.print("[red]No bundled man pages found — broken installation[/red]")
raise typer.Exit(1)
for page in pages:
shutil.copyfile(page, man1 / page.name)
console.print(f"installed {man1 / page.name}")
# Trigger: the chosen root is provably absent from manpath output.
# Why: a silent install into an unsearched directory looks like success
# but `man bm` still fails; say so and hand over the one-line fix.
# Outcome: actionable hint; unknown (None) stays quiet to avoid false alarms.
if _man_root_on_manpath(man_root) is False:
console.print(
f"\n[yellow]{man_root} is not on your manpath.[/yellow] Add it with:\n"
f' export MANPATH="{man_root}:$MANPATH"'
)
console.print("\nTry: [bold]man bm[/bold]")
+1 -3
View File
@@ -5,6 +5,7 @@ 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
@@ -49,9 +50,6 @@ def orphans(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
+3 -13
View File
@@ -20,10 +20,9 @@ from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
console = Console()
@@ -190,9 +189,6 @@ def validate(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -276,9 +272,6 @@ def infer(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -359,9 +352,6 @@ def diff(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
+2 -11
View File
@@ -5,6 +5,7 @@ import json
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
from loguru import logger
from rich.console import Console
@@ -189,16 +190,9 @@ async def run_status(
if sync_report.total == 0:
return project_item.name, sync_report
if time.monotonic() >= deadline:
# Why the hint: indexing is done by the sync coordinator, which
# only runs inside a live server (bm mcp / hosted API). In a
# CLI-only session nothing will ever drain the pending count,
# so this wait cannot succeed — point at the command that
# actually indexes (#959).
raise StatusTimeout(
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
f"to finish indexing ({sync_report.total} pending change(s) remaining). "
f"If no Basic Memory server is running, pending changes are never "
f"indexed — run 'bm reindex --project {project_item.name}' instead."
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
)
await asyncio.sleep(poll_interval)
@@ -230,9 +224,6 @@ def status(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
+12 -40
View File
@@ -14,10 +14,18 @@ from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import delete_note as mcp_delete_note
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
from basic_memory.mcp.tools import search_notes as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
tool_app = typer.Typer()
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
@@ -112,9 +120,6 @@ def write_note(
bm tool write-note --title "My Note" --folder "notes" --overwrite
bm tool write-note --title "My Note" --folder "notes" --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import write_note as mcp_write_note
try:
validate_routing_flags(local, cloud)
@@ -201,9 +206,6 @@ def read_note(
bm tool read-note my-note
bm tool read-note my-note --include-frontmatter
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import read_note as mcp_read_note
try:
validate_routing_flags(local, cloud)
@@ -270,9 +272,6 @@ def delete_note(
bm tool delete-note notes/old-draft
bm tool delete-note docs/archive --is-directory
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import delete_note as mcp_delete_note
try:
validate_routing_flags(local, cloud)
@@ -347,9 +346,6 @@ def edit_note(
bm tool edit-note my-note --operation find_replace --find-text "old" --content "new"
bm tool edit-note my-note --operation replace_section --section "## Notes" --content "updated"
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import edit_note as mcp_edit_note
try:
validate_routing_flags(local, cloud)
@@ -417,9 +413,6 @@ def build_context(
bm tool build-context memory://specs/search
bm tool build-context specs/search --depth 2 --timeframe 30d
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import build_context as mcp_build_context
try:
validate_routing_flags(local, cloud)
@@ -483,9 +476,6 @@ def recent_activity(
bm tool recent-activity --timeframe 30d --page-size 20
bm tool recent-activity --type entity --type observation
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
try:
validate_routing_flags(local, cloud)
@@ -592,9 +582,6 @@ def search_notes(
bm tool search-notes --meta status=draft
bm tool search-notes "auth" --entity-type observation --category requirement
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search
try:
validate_routing_flags(local, cloud)
@@ -700,9 +687,6 @@ def list_projects(
bm tool list-projects
bm tool list-projects --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
try:
validate_routing_flags(local, cloud)
@@ -736,9 +720,6 @@ def list_workspaces(
bm tool list-workspaces
bm tool list-workspaces --cloud
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
try:
validate_routing_flags(local, cloud)
@@ -791,9 +772,6 @@ def schema_validate(
bm tool schema-validate people/ada-lovelace.md
bm tool schema-validate --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
@@ -862,9 +840,6 @@ def schema_infer(
bm tool schema-infer meeting --threshold 0.5
bm tool schema-infer person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
@@ -921,9 +896,6 @@ def schema_diff(
bm tool schema-diff person
bm tool schema-diff person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
-1
View File
@@ -24,7 +24,6 @@ if not _version_only_invocation(sys.argv[1:]):
import_claude_conversations,
import_claude_projects,
import_memory_json,
man,
mcp,
orphans,
project,
+2 -52
View File
@@ -4,7 +4,6 @@ import importlib.util
import json
import os
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
@@ -346,42 +345,6 @@ class BasicMemoryConfig(BaseSettings):
"Valid values: text, vector, hybrid. "
"When unset, defaults to 'hybrid' if semantic search is enabled, otherwise 'text'.",
)
# Entity-aware ranking boost (hybrid retrieval).
# Trigger: proper nouns in a query (e.g. "Joanna") carry no extra weight against
# generic semantic similarity, so documents from the wrong conversation can outrank
# the gold document during hybrid fusion (#951).
# Why: entities are first-class in Basic Memory, so a candidate whose title or linked
# relation names contain a query proper noun is a stronger answer than a same-topic
# document about a different entity.
# Outcome: when enabled, hybrid fusion multiplies a candidate's fused score by a small
# bonus for each distinct query entity term it matches lexically (no model inference).
# Default OFF: LoCoMo benchmarking showed the boost is inert there (its docs are keyed
# by session id, not entity titles) and an adversarial check found Title-Case queries
# can inject spurious entity terms (e.g. "Q3") that regress ranking. See
# docs/semantic-search.md "Benchmark findings".
search_entity_boost_enabled: bool = Field(
default=False,
description="Enable entity-aware ranking boost in hybrid search. When enabled, "
"hybrid candidates whose title or linked relation names contain a proper-noun "
"term from the query are boosted in the final ranking. Lexical-only; adds no "
"model inference. Default off: benchmark-validated as inert on LoCoMo and prone "
"to Title-Case false positives (see docs/semantic-search.md).",
)
search_entity_boost_weight: float = Field(
default=0.15,
description="Per-matched-term multiplier strength for the entity-aware ranking "
"boost. A candidate matching N distinct query entity terms has its fused score "
"multiplied by (1 + weight * N), capped at search_entity_boost_max_terms terms. "
"Only applies when search_entity_boost_enabled is true.",
ge=0.0,
)
search_entity_boost_max_terms: int = Field(
default=3,
description="Maximum number of distinct matched entity terms that contribute to "
"the entity-aware ranking boost, bounding the multiplier so a single candidate "
"cannot run away with the ranking.",
gt=0,
)
# Database connection pool configuration (Postgres only)
db_pool_size: int = Field(
@@ -1136,21 +1099,8 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
_secure_config_dir(file_path.parent)
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
# Trigger: long-lived readers (MCP stdio server config reload, background
# auto-update threads) re-read config.json whenever its mtime changes,
# concurrently with CLI commands saving it.
# Why: writing the destination in place truncates it first, so a concurrent
# reader can observe empty/partial JSON and load_config() exits the process.
# Outcome: write a sibling temp file (unique per process/thread so parallel
# savers cannot interleave) and publish atomically via os.replace — readers
# always see either the old or the new complete document. (#940)
tmp_path = file_path.parent / f"{file_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
tmp_path.write_text(json.dumps(config_dict, indent=2))
_secure_config_file(tmp_path)
os.replace(tmp_path, file_path)
finally:
tmp_path.unlink(missing_ok=True)
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}")
+13 -25
View File
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import (
AsyncEngine,
async_scoped_session,
)
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
from sqlalchemy.pool import NullPool
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
@@ -216,31 +216,19 @@ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
"isolation_level": None, # Use autocommit mode
}
)
if db_type == DatabaseType.MEMORY:
# Trigger: an in-memory SQLite URL would default to StaticPool, which hands the
# same DBAPI connection to every concurrently checked-out session.
# Why: concurrent asyncio tasks then share one transaction scope — a rollback
# issued by one session (scoped_session exception handling or the pool's
# reset-on-return) silently destroys another session's uncommitted writes (#940).
# Outcome: a single-connection blocking queue pool keeps the in-memory database
# alive for the engine's lifetime while serializing sessions at transaction
# granularity, restoring the isolation the repositories assume.
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=AsyncAdaptedQueuePool,
pool_size=1,
max_overflow=0,
)
elif os.name == "nt":
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=NullPool, # Disable connection pooling on Windows
echo=False,
)
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
# between connections
if db_type == DatabaseType.FILESYSTEM:
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=NullPool, # Disable connection pooling on Windows
echo=False,
)
else:
# In-memory databases need connection pooling to maintain state
engine = create_async_engine(db_url, connect_args=connect_args)
else:
engine = create_async_engine(db_url, connect_args=connect_args)
+22 -68
View File
@@ -302,76 +302,26 @@ async def format_file(
return None
# A frontmatter fence is a line containing exactly `---`, optionally followed by
# trailing horizontal whitespace. Anchoring to a full line (rather than a bare
# substring/`startswith`) prevents single-line content like
# `---\nstatus: active\n---\nBody` — where `\n` is a literal backslash-n, not a
# newline — from being misread as frontmatter. See issue #972.
_FENCE_RE = re.compile(r"^---[ \t]*$")
def _split_frontmatter(content: str) -> Optional[tuple[str, str]]:
"""Split content into (yaml_block, body) when it opens with a line-anchored fence.
The opening fence must be the very first line and the closing fence must be a
later line, each matching exactly `---` (with optional trailing whitespace).
Returns:
A `(yaml_block, body)` tuple when a complete fenced block is present, or
``None`` when the content does not open with a frontmatter fence.
Raises:
ParseError: If the content opens with a fence but has no closing fence.
"""
lines = content.splitlines(keepends=True)
# Skip leading blank lines: a document may begin with whitespace before the
# opening fence (e.g. a heredoc/dedented string starting with a newline). This
# does NOT relax line-anchoring — the opening fence must still be the first
# non-blank line, all on its own, so a single-line `---\\nstatus...` (literal
# backslash-n) is still rejected. See issue #972.
start = 0
while start < len(lines) and lines[start].strip() == "":
start += 1
if start >= len(lines) or not _FENCE_RE.match(lines[start].rstrip("\r\n")):
return None
# Find the closing fence on its own line somewhere after the opening fence.
for index in range(start + 1, len(lines)):
if _FENCE_RE.match(lines[index].rstrip("\r\n")):
yaml_block = "".join(lines[start + 1 : index])
body = "".join(lines[index + 1 :])
return yaml_block, body
raise ParseError("Invalid frontmatter format")
def has_frontmatter(content: str) -> bool:
"""
Check if content contains valid YAML frontmatter.
Frontmatter requires `---` fences on their own lines; an inline `---` (such as
a single-line string that merely starts with the characters `---`) is not
frontmatter.
Args:
content: Content to check
Returns:
True if content has line-anchored frontmatter fences, False otherwise
True if content has valid frontmatter markers (---), False otherwise
"""
if not content:
return False
# Strip BOM before checking for frontmatter markers
content = strip_bom(content)
try:
return _split_frontmatter(content) is not None
except ParseError:
# An opening fence with no closing fence is not usable frontmatter.
content = strip_bom(content).strip()
if not content.startswith("---"):
return False
return "---" in content[3:]
def parse_frontmatter(content: str) -> Dict[str, Any]:
"""
@@ -389,14 +339,17 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
try:
# Strip BOM before parsing frontmatter
content = strip_bom(content)
split = _split_frontmatter(content)
if split is None:
if not content.strip().startswith("---"):
raise ParseError("Content has no frontmatter")
yaml_block, _ = split
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
# Parse YAML
try:
frontmatter = yaml.safe_load(yaml_block)
frontmatter = yaml.safe_load(parts[1])
# Handle empty frontmatter (None from yaml.safe_load)
if frontmatter is None:
return {}
@@ -428,17 +381,18 @@ def remove_frontmatter(content: str) -> str:
ParseError: If content starts with frontmatter marker but is malformed
"""
# Strip BOM before processing
content = strip_bom(content)
content = strip_bom(content).strip()
split = _split_frontmatter(content)
# Trigger: content does not open with a line-anchored fence
# Why: inline `---` is ordinary content, not frontmatter (issue #972)
# Outcome: return the content untouched (stripped to preserve prior behavior)
if split is None:
return content.strip()
# Return as-is if no frontmatter marker
if not content.startswith("---"):
return content
_, body = split
return body.strip()
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
return parts[2].strip()
def dump_frontmatter(post: frontmatter.Post) -> str:
-1
View File
@@ -1 +0,0 @@
.so man1/bm.1
-134
View File
@@ -1,134 +0,0 @@
.TH BM 1 "2026-06-11" "basic-memory" "Basic Memory Manual"
.SH NAME
bm \- local-first knowledge base for humans and AI agents
.SH SYNOPSIS
.B bm
.I COMMAND
.RI [ ARGS ]...
.br
.B basic-memory
.I COMMAND
.RI [ ARGS ]...
.SH DESCRIPTION
.B bm
manages Basic Memory projects: plain markdown files that form a knowledge
graph. Files are the source of truth; SQLite provides indexing and
full-text search; the same operations are exposed to AI agents over the
Model Context Protocol (MCP) and to humans and scripts through this CLI.
.PP
Notes use semantic markdown: observations
.RB ( "\- [category] text #tag" )
and relations
.RB ( "\- relation_type [[Target]]" )
become queryable graph structure. Projects route independently to the
local API or to Basic Memory Cloud.
.SH COMMANDS
Knowledge operations:
.TP
.B bm tool
CLI access to the MCP tools (write-note, read-note, search-notes,
build-context, ...). These emit JSON and are the scriptable surface.
.TP
.B bm status
Show sync status between files and the database.
.TP
.B bm reindex
Index local file changes and rebuild search/embeddings. This is the manual
sync trigger when no MCP server is running.
.TP
.B bm doctor
Run end-to-end file/database consistency checks.
.TP
.B bm orphans
List entities with no relations in the knowledge graph.
.TP
.B bm format
Run configured formatters over note files.
.PP
Projects and schemas:
.TP
.B bm project
Add, remove, list projects; set the default; flip a project between local
and cloud routing.
.TP
.B bm schema
List, validate, infer, and drift-check Picoschema note-type contracts.
.PP
Data and cloud:
.TP
.B bm import
Import from ChatGPT, Claude, or memory.json exports. Imports write files;
run
.B bm reindex
afterwards.
.TP
.B bm cloud
Authenticate, sync (push/pull/sync/bisync), snapshots, and team workspace
administration.
.PP
Infrastructure:
.TP
.B bm mcp
Run the MCP server (hosts the live file watcher).
.TP
.B bm man
Manage these man pages
.RB ( "bm man install" ).
.TP
.B bm reset
Drop and recreate the database (destructive).
.PP
Most commands accept
.B \-\-project
.I NAME
to target a project and
.BR \-\-local / \-\-cloud
to override routing.
.SH EXAMPLES
Write and find a note from the shell:
.PP
.nf
.RS
echo "# Standup notes" | bm tool write-note \\
\-\-title "Standup" \-\-folder notes
bm tool search-notes "standup"
.RE
.fi
.PP
Pick up files created outside the tools:
.PP
.nf
.RS
bm status # shows pending changes
bm reindex # indexes them
.RE
.fi
.SH FILES
.TP
.I ~/.basic-memory/config.json
Projects, default project, per-project routing modes, cloud settings.
.TP
.I ~/.basic-memory/memory.db
SQLite index (derived; safe to rebuild with bm reindex).
.SH ENVIRONMENT
.TP
.B BASIC_MEMORY_FORCE_LOCAL
Force local routing regardless of cloud mode.
.TP
.B BASIC_MEMORY_LOG_LEVEL
Logging verbosity (e.g. DEBUG).
.SH SEE ALSO
Full manual (machine-readable, agent-traversable): the
.I manual
Basic Memory project \(em see docs/manual-pages.md in the repository.
Documentation: https://docs.basicmemory.com
.PP
For AI agents: the complete tool reference lives in the manual project as
section\-3 pages (write-note(3), search-notes(3), ...), queryable via
.B search_notes
with
.BR "metadata_filters={\(dqtype\(dq: \(dqmanpage\(dq}" .
.SH BUGS
https://github.com/basicmachines-co/basic-memory/issues
.SH AUTHORS
Basic Machines (https://basicmachines.co)
+13 -38
View File
@@ -5,16 +5,14 @@ from dataclasses import dataclass
from threading import RLock
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
import logfire
from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials
from basic_memory.config import ConfigManager, ProjectMode
if TYPE_CHECKING:
# FastAPI is only needed when a request routes through the local ASGI
# transport; importing it at module level costs ~0.1s on every CLI start (#886).
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
@@ -30,8 +28,8 @@ class _PreparedLocalAsgiDatabase:
_prepared_local_asgi_database_lock = RLock()
_prepared_local_asgi_database_prepare_locks: dict["FastAPI", Lock] = {}
_prepared_local_asgi_databases: dict["FastAPI", _PreparedLocalAsgiDatabase] = {}
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
def _force_local_mode() -> bool:
@@ -59,7 +57,7 @@ def _build_timeout() -> Timeout:
)
def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient:
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
@@ -73,7 +71,7 @@ def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient:
)
def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock:
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)
@@ -84,10 +82,8 @@ def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock:
@asynccontextmanager
async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDatabaseState]:
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
"""Resolve database state for a local ASGI request."""
# Imported on first local-ASGI use so CLI startup never pays for FastAPI (#886).
from fastapi import Depends, Request
from fastapi.dependencies.utils import get_dependant, solve_dependencies
from basic_memory.deps import get_engine_factory
@@ -131,7 +127,7 @@ async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDat
yield await resolve_database_state(**solved.values)
def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool:
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)
@@ -143,7 +139,7 @@ def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool:
def _install_prepared_local_asgi_database(
app: "FastAPI",
app: FastAPI,
database_state: LocalDatabaseState,
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
) -> None:
@@ -167,7 +163,7 @@ def _install_prepared_local_asgi_database(
)
def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_value: object) -> None:
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):
@@ -177,7 +173,7 @@ def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_valu
def _release_prepared_local_asgi_database(
app: "FastAPI",
app: FastAPI,
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
"""Release local ASGI database state after a client context exits."""
with _prepared_local_asgi_database_lock:
@@ -200,7 +196,7 @@ def _release_prepared_local_asgi_database(
@asynccontextmanager
async def _prepared_local_asgi_database(app: "FastAPI") -> AsyncIterator[None]:
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:
@@ -372,8 +368,7 @@ async def get_client(
1. Factory injection.
2. Explicit routing flags (--local/--cloud).
3. Per-project mode routing when project_name is provided.
4. Cloud routing when a workspace selector is provided.
5. Local ASGI transport by default.
4. Local ASGI transport by default.
"""
if _client_factory:
async with _client_factory(workspace=workspace) as client:
@@ -433,26 +428,6 @@ async def get_client(
yield client
return
# --- Workspace-selector routing ---
# Trigger: caller passed a cloud workspace selector and nothing above routed.
# Why: a workspace names a cloud tenant — silently serving the request from
# the local ASGI app sent writes to the wrong destination (#954: a cloud
# project create either failed on the cloud-style path or landed locally).
# Outcome: route to the cloud proxy when credentials exist; without
# credentials fail fast instead of pretending the operation succeeded.
if workspace is not None:
if not has_cloud_credentials(config):
raise RuntimeError(
f"A cloud workspace was requested ('{workspace}') but no cloud "
"credentials were found. Run 'bm cloud login' or "
"'bm cloud set-key <key>' first, or omit the workspace selector "
"for a local operation."
)
logger.debug(f"Workspace selector '{workspace}' provided - using cloud proxy client")
async with _cloud_client(config, timeout, workspace=workspace) as client:
yield client
return
# --- Default fallback ---
logger.debug("Default routing - using ASGI client for local Basic Memory API")
async with _asgi_client(timeout) as client:
+1 -5
View File
@@ -7,9 +7,7 @@ from typing import Optional, Any
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
class DirectoryClient:
@@ -57,8 +55,6 @@ class DirectoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"dir_name": dir_name,
"depth": depth,
+1 -26
View File
@@ -8,10 +8,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
from basic_memory.schemas.response import (
EntityResponse,
DeleteEntitiesResponse,
@@ -60,8 +57,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.create_entity",
client_name="knowledge",
@@ -94,8 +89,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.update_entity",
client_name="knowledge",
@@ -123,8 +116,6 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_entity",
client_name="knowledge",
@@ -156,8 +147,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
with logfire.span(
"mcp.client.knowledge.patch_entity",
client_name="knowledge",
@@ -185,8 +174,6 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_delete
with logfire.span(
"mcp.client.knowledge.delete_entity",
client_name="knowledge",
@@ -214,8 +201,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.move_entity",
client_name="knowledge",
@@ -246,8 +231,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.move_directory",
client_name="knowledge",
@@ -278,8 +261,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.delete_directory",
client_name="knowledge",
@@ -309,8 +290,6 @@ class KnowledgeClient:
Raises:
ToolError: If the file does not exist on disk or indexing fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.sync_file",
client_name="knowledge",
@@ -330,8 +309,6 @@ class KnowledgeClient:
async def get_orphans(self) -> list[GraphNode]:
"""Get entities that have no incoming or outgoing relations."""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_orphans",
client_name="knowledge",
@@ -361,8 +338,6 @@ class KnowledgeClient:
Raises:
ToolError: If the identifier cannot be resolved
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.resolve_entity",
client_name="knowledge",
+1 -8
View File
@@ -8,10 +8,7 @@ from typing import Optional
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import GraphContext
@@ -66,8 +63,6 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"depth": depth,
"page": page,
@@ -118,8 +113,6 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"timeframe": timeframe,
"depth": depth,
+7 -21
View File
@@ -7,9 +7,13 @@ from typing import Any
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import (
call_delete,
call_get,
call_patch,
call_post,
call_put,
)
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.schemas.v2 import ProjectResolveResponse
@@ -49,8 +53,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
"/v2/projects/",
@@ -69,8 +71,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/",
@@ -93,8 +93,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_delete
url = f"/v2/projects/{project_external_id}"
if delete_notes:
url += "?delete_notes=true"
@@ -116,8 +114,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/resolve",
@@ -137,8 +133,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
response = await call_put(
self.http_client,
f"/v2/projects/{project_external_id}/default",
@@ -160,8 +154,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
response = await call_patch(
self.http_client,
f"/v2/projects/{project_external_id}",
@@ -189,8 +181,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
url = f"/v2/projects/{project_external_id}/sync"
params = []
if force_full:
@@ -214,8 +204,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"/v2/projects/{project_external_id}/status",
@@ -234,8 +222,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"/v2/projects/{project_external_id}/info",
+1 -5
View File
@@ -6,9 +6,7 @@ Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
from httpx import AsyncClient, Response
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
class ResourceClient:
@@ -51,8 +49,6 @@ class ResourceClient:
Raises:
ToolError: If the resource is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.resource.read",
client_name="resource",
+1 -9
View File
@@ -5,9 +5,7 @@ Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.schemas.schema import (
ValidationReport,
InferenceReport,
@@ -58,8 +56,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
params: dict[str, str] = {}
if note_type:
params["note_type"] = note_type
@@ -91,8 +87,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"{self._base_path}/infer",
@@ -112,8 +106,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"{self._base_path}/diff/{note_type}",
+1 -6
View File
@@ -8,10 +8,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.search import SearchResponse
@@ -60,8 +57,6 @@ class SearchClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.search.search",
client_name="search",
+23 -164
View File
@@ -8,24 +8,10 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
# PEP 563 lazy annotations keep `Context` usable in signatures without importing
# fastmcp at module load — the fastmcp/mcp stack costs ~0.5s of CLI startup (#886).
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, nullcontext
from typing import (
TYPE_CHECKING,
AsyncIterator,
Awaitable,
Callable,
List,
Optional,
Sequence,
Tuple,
cast,
)
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
from uuid import UUID
from httpx import AsyncClient
@@ -33,6 +19,8 @@ from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
@@ -58,9 +46,6 @@ from basic_memory.workspace_context import (
workspace_permalink_context,
)
if TYPE_CHECKING:
from fastmcp import Context
# --- Workspace provider injection ---
# Mirrors the set_client_factory() pattern in async_client.py.
# The cloud MCP server sets a provider that queries its own database directly,
@@ -69,14 +54,6 @@ _workspace_provider: Optional[Callable[[], Awaitable[list[WorkspaceInfo]]]] = No
_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
class WorkspaceProjectLookupMiss(ValueError):
"""A project was absent from the workspace index (as opposed to ambiguous).
Misses are retried once against a freshly rebuilt index, because the
session cache may simply predate an out-of-band project creation (#956).
"""
@dataclass(frozen=True)
class WorkspaceProjectEntry:
"""A cloud project resolved together with the workspace that owns it."""
@@ -483,17 +460,6 @@ def _canonical_memory_path_for_workspace(
# Outcome: lookups preserve the complete workspace/project canonical permalink.
if not normalized_remainder:
normalized_remainder = project_permalink
# Same index-form rule as _canonical_memory_path_for_active_route (#957):
# without an active workspace permalink context, stored permalinks are
# project-qualified and a workspace-prefixed pattern cannot match.
if "*" in normalized_remainder and current_workspace_permalink_context() is None:
return build_qualified_permalink_reference(
project_permalink,
normalized_remainder,
include_project=True,
)
return build_qualified_permalink_reference(
project_permalink,
normalized_remainder,
@@ -511,24 +477,6 @@ def _canonical_memory_path_for_active_route(
) -> str:
"""Return the canonical permalink path for the currently routed project/workspace."""
project_prefix = active_project.permalink
# Trigger: the path contains a glob wildcard (folder/*) and no server-side
# workspace permalink context is active.
# Why: patterns match raw against the search index, so they must mirror the
# stored permalink form. The contextvar is what qualified permalinks at
# write time — when it is absent, stored rows are project-qualified and a
# workspace prefix (from the client's cached_workspace display state)
# guarantees zero matches (#957). Direct lookups keep full qualification
# because the link resolver understands it; patterns have no fallback.
# Outcome: without the contextvar, qualify patterns with the project prefix
# only; with it, fall through to normal workspace canonicalization.
if "*" in path and current_workspace_permalink_context() is None:
if not include_project:
return path
if path == project_prefix or path.startswith(f"{project_prefix}/"):
return path
return f"{project_prefix}/{path}"
workspace_remainder = path
if include_project and (path == project_prefix or path.startswith(f"{project_prefix}/")):
# Trigger: the memory URL already names the active project root/prefix
@@ -774,15 +722,9 @@ async def _fetch_workspace_project_entries(
async def _ensure_workspace_project_index(
context: Optional[Context] = None,
*,
force_refresh: bool = False,
) -> WorkspaceProjectIndex:
"""Build or load the session-local workspace/project lookup index.
force_refresh bypasses the cached index and rebuilds from discovery
used by resolve_workspace_project_identifier when a lookup misses (#956).
"""
if context and not force_refresh:
"""Build or load the session-local workspace/project lookup index."""
if context:
cached_raw = await context.get_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY)
cached_index = _workspace_project_index_from_state(cached_raw)
if cached_index is not None:
@@ -856,99 +798,13 @@ async def ensure_workspace_project_index(
return await _ensure_workspace_project_index(context=context)
def _match_workspace_identifier(
workspaces: tuple[WorkspaceInfo, ...],
workspace_identifier: str,
) -> WorkspaceInfo:
"""Resolve the first segment of a qualified route to a single workspace.
The edit_note/write_note contract advertises that the workspace segment may be a
slug, tenant_id, or display name. We honor those forms in a fixed priority order so
that adding tenant_id/name support never changes the meaning of an identifier that
already resolves today:
1. slug (casefold) existing behavior, checked first so working routes are stable.
2. tenant_id exact match against the opaque id (no casefolding, mirroring the
precedent in ``workspace_matches_exact_identifier``).
3. display name (casefold) names are not guaranteed unique, so a name that matches
multiple workspaces is rejected rather than silently picking one.
"""
# Trigger: identifier equals a workspace slug (casefold).
# Why: slug is the canonical routing key; resolving it first guarantees a workspace
# whose display name collides with another workspace's slug yields to the slug owner.
# Outcome: return the slug owner before tenant_id/name are considered.
slug_matches = [
workspace
for workspace in workspaces
if workspace.slug.casefold() == workspace_identifier.casefold()
]
if slug_matches:
return slug_matches[0]
# Trigger: identifier exactly equals a workspace tenant_id (an opaque id).
# Why: tenant_ids are unique, so an exact hit is unambiguous and needs no tie-break.
tenant_matches = [
workspace for workspace in workspaces if workspace.tenant_id == workspace_identifier
]
if tenant_matches:
return tenant_matches[0]
# Trigger: identifier matches one or more workspace display names (casefold).
# Why: names are not guaranteed unique; failing fast on collisions keeps routing
# deterministic and tells the caller exactly how to disambiguate.
name_matches = [
workspace
for workspace in workspaces
if workspace.name.casefold() == workspace_identifier.casefold()
]
if len(name_matches) > 1:
candidates = ", ".join(workspace.slug for workspace in name_matches)
raise ValueError(
f"Workspace name '{workspace_identifier}' matched multiple workspaces "
f"(slugs: {candidates}). Use the workspace slug or tenant_id to disambiguate."
)
if name_matches:
return name_matches[0]
available = ", ".join(workspace.slug for workspace in workspaces)
raise ValueError(
f"Workspace '{workspace_identifier}' was not found by slug, tenant_id, or name. "
f"Available workspace slugs: {available}"
)
async def resolve_workspace_project_identifier(
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve a project by external_id (UUID), qualified name, or unqualified name."""
index = await _ensure_workspace_project_index(context=context)
try:
return await _resolve_workspace_project_from_index(index, project, context)
except WorkspaceProjectLookupMiss:
# Trigger: the lookup missed the session-cached index.
# Why: a miss is exactly the signal the cache may be stale — projects
# created out-of-band (CLI, a teammate in a shared workspace) post-date
# the index built at session start (#956).
# Outcome: rebuild the index once and retry; a second miss is authoritative
# and its error (with the refreshed project list) propagates.
logger.info(
f"Workspace project lookup missed for '{project}'; refreshing index and retrying"
)
refreshed = await _ensure_workspace_project_index(context=context, force_refresh=True)
return await _resolve_workspace_project_from_index(refreshed, project, context)
async def _resolve_workspace_project_from_index(
index: WorkspaceProjectIndex,
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve a project against one concrete index snapshot.
Raises WorkspaceProjectLookupMiss for absent projects (retryable via index
refresh) and plain ValueError for ambiguity, which a refresh cannot fix.
"""
# Fast path: direct lookup by external_id when the identifier is a UUID
# Canonicalize via str(UUID(...)) so uppercase, brace-wrapped, or urn:uuid forms
# all hash to the same lowercase-hyphenated key as the stored external_ids.
@@ -960,14 +816,23 @@ async def _resolve_workspace_project_from_index(
except ValueError:
pass
workspace_identifier, project_identifier = _split_qualified_project_identifier(project)
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
project_permalink = generate_permalink(project_identifier)
if workspace_identifier:
# Honor the documented "slug, name, or tenant_id" contract for the workspace
# segment; _match_workspace_identifier raises a clear error on ambiguous names
# and unknown identifiers, listing what forms were tried.
workspace = _match_workspace_identifier(index.workspaces, workspace_identifier)
if workspace_slug:
workspace_matches = [
workspace
for workspace in index.workspaces
if workspace.slug.casefold() == workspace_slug.casefold()
]
if not workspace_matches:
available = ", ".join(workspace.slug for workspace in index.workspaces)
raise ValueError(
f"Workspace '{workspace_slug}' was not found. "
f"Available workspace slugs: {available}"
)
workspace = workspace_matches[0]
matches = [
entry
for entry in index.entries_by_permalink.get(project_permalink, ())
@@ -978,7 +843,7 @@ async def _resolve_workspace_project_from_index(
failed_workspace.tenant_id == workspace.tenant_id
for failed_workspace in index.failed_workspaces
):
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
"could not be loaded. Retry after workspace discovery recovers."
)
@@ -987,7 +852,7 @@ async def _resolve_workspace_project_from_index(
for entry in index.entries
if entry.workspace.tenant_id == workspace.tenant_id
)
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Project '{project_identifier}' was not found in workspace "
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
)
@@ -1012,7 +877,7 @@ async def _resolve_workspace_project_from_index(
"retry or use a qualified project from an indexed workspace."
)
available = ", ".join(entry.qualified_name for entry in index.entries)
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Project '{project}' was not found in indexed cloud workspaces. "
f"Available projects: {available}.{failed_note}"
)
@@ -1347,9 +1212,6 @@ async def resolve_project_and_path(
# Why: allow project-scoped memory URLs without requiring a separate project parameter
# Outcome: attempt to resolve the prefix as a project and route to it
if project_prefix:
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
if cached_project and _project_matches_identifier(cached_project, project_prefix):
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
@@ -1576,9 +1438,6 @@ async def get_project_client(
is_factory_mode,
)
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# When project_id (UUID) is provided, prefer it as the resolution identifier.
# external_id is unambiguous across workspaces; project name can collide.
project_identifier = project_id if project_id else project
@@ -1,17 +0,0 @@
"""Shared loader for the bundled cloud-discovery markdown resources."""
from pathlib import Path
def load_discovery_resource(filename: str) -> str:
"""Read a bundled discovery markdown file with promo placeholders rendered.
The markdown carries a {{OSS_DISCOUNT_CODE}} placeholder so the promo code
has one source of truth (cli.promo); substitute before it reaches users.
"""
# Import here to avoid pulling CLI promo machinery (analytics, rich, config)
# into the MCP server import graph at module load.
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
content = (Path(__file__).parent / filename).read_text(encoding="utf-8")
return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE)
+4
View File
@@ -36,7 +36,11 @@ from basic_memory.mcp.tools.chatgpt_tools import search, fetch
# Schema tools
from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff
# Diagnostics tool
from basic_memory.mcp.tools.basic_memory_diagnostics import basic_memory_diagnostics
__all__ = [
"basic_memory_diagnostics",
"build_context",
"canvas",
"cloud_info",
@@ -0,0 +1,132 @@
"""Diagnostic tool for Basic Memory version and system information."""
import json
import platform
import sys
from urllib.parse import urlparse, urlunparse
import basic_memory
from basic_memory.config import CONFIG_FILE_NAME, ConfigManager
from basic_memory.mcp.server import mcp
# Fields in BasicMemoryConfig that contain secrets and must never be surfaced.
_SECRET_FIELDS = frozenset({"cloud_api_key"})
# Fields whose values are URLs that may embed user:password credentials.
# The userinfo component is stripped before surfacing.
_URL_FIELDS = frozenset({"database_url"})
def _redact_url(url: str) -> str:
"""Strip the userinfo (user:password) from a URL string.
Replaces any credentials with *** so the host/path remain visible for
diagnostics (e.g. ``postgresql://***@localhost/mydb``). If the value
cannot be parsed as a URL it is returned unchanged.
"""
try:
parsed = urlparse(url)
except Exception: # pragma: no cover — urlparse is very permissive
return url
if not parsed.hostname:
# Not a meaningful URL (e.g. a bare file path); leave it alone.
return url
if parsed.username or parsed.password:
# Rebuild with credentials replaced by a placeholder.
netloc = f"***@{parsed.hostname}"
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc)
return urlunparse(redacted)
return url
def _redact_config(raw: dict) -> dict:
"""Return a copy of the raw config dict with secret fields removed.
- Keys in ``_SECRET_FIELDS`` are dropped entirely.
- Keys in ``_URL_FIELDS`` have their userinfo component stripped so that
host and database name remain visible for diagnostics.
Only top-level keys are processed. Nested keys within project entries are
not currently credential-bearing, but the two sets make the pattern easy
to extend.
"""
result: dict = {}
for k, v in raw.items():
if k in _SECRET_FIELDS:
# Drop entirely — value has no diagnostic value.
continue
if k in _URL_FIELDS and isinstance(v, str):
result[k] = _redact_url(v)
else:
result[k] = v
return result
@mcp.tool(
"basic_memory_diagnostics",
title="Basic Memory Diagnostics",
tags={"diagnostics"},
annotations={"readOnlyHint": True, "openWorldHint": False},
# The report is a markdown string; suppress FastMCP's wrap_result so the
# payload isn't duplicated into structuredContent.
output_schema=None,
)
def basic_memory_diagnostics() -> str:
"""Return version, system, and configuration diagnostics for Basic Memory.
Provides:
- Basic Memory package version
- Python version and platform details
- Config file path and its contents (secrets redacted)
Useful for troubleshooting installations and gathering information for
support requests. Read-only; never emits secrets or API keys.
"""
# --- Version information ---
bm_version = basic_memory.__version__
# --- System information ---
python_version = sys.version
platform_info = platform.platform()
machine = platform.machine()
# --- Configuration ---
manager = ConfigManager()
config_file = manager.config_dir / CONFIG_FILE_NAME
config_exists = config_file.exists()
if config_exists:
try:
raw_config = json.loads(config_file.read_text(encoding="utf-8"))
safe_config = _redact_config(raw_config)
config_dump = json.dumps(safe_config, indent=2, default=str)
except Exception as exc: # pragma: no cover
config_dump = f"<error reading config: {exc}>"
else:
config_dump = "<config file not found>"
lines = [
"# Basic Memory Diagnostics",
"",
"## Version",
f"- basic-memory: {bm_version}",
"",
"## System",
f"- Python: {python_version}",
f"- Platform: {platform_info}",
f"- Architecture: {machine}",
"",
"## Configuration",
f"- Config path: {config_file}",
f"- Config exists: {config_exists}",
"",
"```json",
config_dump,
"```",
]
return "\n".join(lines)
+1 -6
View File
@@ -54,10 +54,7 @@ def _format_entity_block(result: ContextResult) -> str:
lines.append("")
lines.append("### Relations")
for rel in relation_items:
# Unresolved forward references have no resolved entity yet; fall back
# to the literal target text instead of rendering [[None]] (#955)
target = rel.to_entity or rel.to_name
lines.append(f"- {rel.relation_type} [[{target}]]")
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
# --- Related entities (non-relation related results) ---
related_entities: list[EntitySummary | ObservationSummary] = [
@@ -115,7 +112,6 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
@mcp.tool(
title="Build Context",
description="""Build context from a memory:// URI to continue conversations naturally.
Use this to follow up on previous discussions or explore related topics.
@@ -135,7 +131,6 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
- "json" (default): Structured JSON with internal fields excluded
- "text": Compact markdown text for LLM consumption
""",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
-2
View File
@@ -17,9 +17,7 @@ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
@mcp.tool(
title="Create Canvas",
description="Create an Obsidian canvas file to visualize concepts and connections.",
tags={"canvas", "notes"},
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
)
async def canvas(
@@ -105,9 +105,7 @@ def _format_document_for_chatgpt(
@mcp.tool(
title="Search Knowledge Base",
description="Search for content across the knowledge base",
tags={"search"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search(
@@ -169,9 +167,7 @@ async def search(
@mcp.tool(
title="Fetch Document",
description="Fetch the full contents of a search result document",
tags={"search", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def fetch(
+4 -4
View File
@@ -1,15 +1,15 @@
"""Cloud information MCP tool."""
from basic_memory.mcp.resources.discovery import load_discovery_resource
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool(
"cloud_info",
title="Cloud Info",
tags={"cloud"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def cloud_info() -> str:
"""Return optional Basic Memory Cloud information and setup guidance."""
return load_discovery_resource("cloud_info.md")
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
return content_path.read_text(encoding="utf-8")
@@ -182,9 +182,7 @@ def _directory_path_for_delete(
@mcp.tool(
title="Delete Note",
description="Delete a note or directory by title, permalink, or path",
tags={"notes"},
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_note(
-2
View File
@@ -304,9 +304,7 @@ Error editing note '{identifier}': {error_message}
@mcp.tool(
title="Edit Note",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
tags={"notes"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
@@ -11,9 +11,7 @@ from basic_memory.mcp.server import mcp
@mcp.tool(
title="List Directory",
description="List directory contents with filtering and depth control.",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_directory(
-2
View File
@@ -357,9 +357,7 @@ delete_note("{identifier}")
@mcp.tool(
title="Move Note",
description="Move a note or directory to a new location, updating database and maintaining links.",
tags={"notes"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def move_note(
@@ -357,8 +357,6 @@ def _format_project_list_json(
@mcp.tool(
"list_memory_projects",
title="List Memory Projects",
tags={"projects"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_memory_projects(
@@ -477,14 +475,10 @@ async def _resolve_workspace_routing(
if workspace is None:
return None
forced_local = _explicit_routing() and _force_local_mode()
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
config = ConfigManager().config
# Resolve whenever credentials make workspace discovery possible — not only
# under explicit --cloud. A workspace selector implies cloud routing
# (get_client routes it to the cloud proxy, #954), and the transport needs
# the tenant id in X-Workspace-ID, not a slug or display name.
should_resolve_workspace = is_factory_mode() or (
has_cloud_credentials(config) and not forced_local
explicit_cloud_routing and has_cloud_credentials(config)
)
if not should_resolve_workspace:
return workspace
@@ -501,8 +495,6 @@ async def _resolve_workspace_routing(
@mcp.tool(
"create_memory_project",
title="Create Memory Project",
tags={"projects"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def create_memory_project(
@@ -525,9 +517,8 @@ async def create_memory_project(
workspace: Optional cloud workspace selector to create the project in. Slug is
preferred for AI callers, but tenant_id and unique name are also accepted.
When omitted, the connection's default workspace is used. Discover values
via `list_workspaces`. A workspace selector implies cloud routing:
without cloud credentials the call fails fast instead of silently
creating a local project (#954).
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.
@@ -645,8 +636,6 @@ async def create_memory_project(
@mcp.tool(
title="Delete Project",
tags={"projects"},
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(
@@ -665,9 +654,8 @@ async def delete_project(
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. A workspace selector implies cloud routing: without cloud
credentials the call fails fast, matching create_memory_project
behavior (#954).
used. In local mode the selector is passed through without slug
resolution, matching create_memory_project behavior.
Returns:
Confirmation message about project deletion
@@ -155,9 +155,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
@mcp.tool(
title="Read Content",
description="Read a file's raw content by path or permalink",
tags={"notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_content(
-2
View File
@@ -74,9 +74,7 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
@mcp.tool(
title="Read Note",
description="Read a markdown note by title or permalink.",
tags={"notes"},
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -26,7 +26,6 @@ from basic_memory.schemas.search import SearchItemType
@mcp.tool(
title="Recent Activity",
description="""Get recent activity for a project or across all projects.
Timeframe supports natural language formats like:
@@ -37,7 +36,6 @@ from basic_memory.schemas.search import SearchItemType
- "3 weeks ago"
Or standard formats like "7d"
""",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def recent_activity(
+4 -4
View File
@@ -1,15 +1,15 @@
"""Release notes MCP tool."""
from basic_memory.mcp.resources.discovery import load_discovery_resource
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool(
"release_notes",
title="Release Notes",
tags={"cloud"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def release_notes() -> str:
"""Return the latest product release notes for optional user review."""
return load_discovery_resource("release_notes.md")
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
return content_path.read_text(encoding="utf-8")
-6
View File
@@ -204,9 +204,7 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str:
@mcp.tool(
title="Validate Schema",
description="Validate notes against their Picoschema definitions.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_validate(
@@ -319,9 +317,7 @@ async def schema_validate(
@mcp.tool(
title="Infer Schema",
description="Analyze existing notes and suggest a Picoschema definition.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_infer(
@@ -442,9 +438,7 @@ async def schema_infer(
@mcp.tool(
title="Schema Diff",
description="Detect drift between a schema definition and actual note usage.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_diff(
+5 -29
View File
@@ -14,7 +14,7 @@ from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.utils import (
build_canonical_permalink,
coerce_dict,
parse_str_list,
coerce_list,
parse_tags,
strict_search_tags,
)
@@ -612,9 +612,7 @@ async def _search_all_projects(
@mcp.tool(
title="Search Notes",
description="Search across all content in the knowledge base with advanced syntax support.",
tags={"search"},
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -651,30 +649,24 @@ async def search_notes(
# Plural-vs-singular trips models constantly. Accept the singular too.
note_types: Annotated[
List[str] | None,
# parse_str_list, not coerce_list: "note,task" must split into ["note", "task"]
# consistent with how tags are handled (#910/#930). coerce_list wraps the whole
# comma string as the single literal type ["note,task"], which matches nothing.
BeforeValidator(parse_str_list),
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'). "
"Accepts a list, a comma-separated string (e.g. 'note,task'), or a JSON-array string. "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
BeforeValidator(parse_str_list),
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. "
"Accepts a list, a comma-separated string (e.g. 'entity,observation'), or a JSON-array string.",
"'Chapter' here — use note_types instead.",
] = None,
categories: Annotated[
List[str] | None,
BeforeValidator(parse_str_list),
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("categories", "category")),
"Filter observation results to these exact categories (e.g. ['requirement']). "
"Accepts a list, a comma-separated string (e.g. 'requirement,decision'), or a JSON-array string. "
"Pair with entity_types=['observation'] to return only observations whose "
"category matches exactly — not every row mentioning the word.",
] = None,
@@ -828,11 +820,6 @@ async def search_notes(
Formatted markdown text (output_format="text"), dict (output_format="json"),
or helpful error guidance string if search fails
Pagination note: `total` is exact only for text/title/permalink searches.
Vector and hybrid searches skip the count query (it would cost a second
semantic retrieval pass) and report `total: 0` even when results are
returned use `has_more` for pagination in those modes.
Examples:
# Basic text search
results = await search_notes("project planning")
@@ -906,17 +893,6 @@ async def search_notes(
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Trigger: list params arrived via a direct function call instead of the MCP layer.
# Why: the BeforeValidator annotations only run through MCP/Pydantic validation; direct
# callers (e.g. `bm tool search-notes --type note,task` in cli/commands/tool.py,
# which Typer collects as the one-element list ["note,task"]) would otherwise
# forward the comma string as one literal type that matches nothing (#930).
# Outcome: comma-split/list normalization applies on every path; parse_str_list is
# idempotent, so MCP-validated input passes through unchanged.
note_types = parse_str_list(note_types) if note_types is not None else []
entity_types = parse_str_list(entity_types) if entity_types is not None else []
categories = parse_str_list(categories) if categories is not None else []
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
-4
View File
@@ -18,9 +18,7 @@ def _text_block(message: str) -> List[ContentBlock]:
@mcp.tool(
title="Search Notes (UI)",
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
tags={"search", "ui"},
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)
@@ -94,9 +92,7 @@ async def search_notes_ui(
@mcp.tool(
title="Read Note (UI)",
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
tags={"notes", "ui"},
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)
-2
View File
@@ -11,9 +11,7 @@ from basic_memory.mcp.tools.read_note import read_note
@mcp.tool(
title="View Note",
description="View a note as a formatted artifact for better readability.",
tags={"notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def view_note(
-2
View File
@@ -45,9 +45,7 @@ def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListRe
@mcp.tool(
title="List Workspaces",
description="List available cloud workspaces (tenant_id, type, role, and name).",
tags={"cloud", "projects"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_workspaces(
-39
View File
@@ -25,39 +25,8 @@ from basic_memory.workspace_context import current_workspace_permalink_context
TagType = Union[List[str], str, None]
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}"
@mcp.tool(
title="Write Note",
description="Create a markdown note. If the note already exists, returns an error by default — pass overwrite=True to replace.",
tags={"notes"},
annotations={"destructiveHint": True, "idempotentHint": False, "openWorldHint": False},
)
async def write_note(
@@ -69,7 +38,6 @@ async def write_note(
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
project_id: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
@@ -125,8 +93,6 @@ async def write_note(
form (or project_id) to disambiguate. If unknown, use
list_memory_projects() to discover available projects and their
qualified names.
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().
@@ -204,11 +170,6 @@ async def write_note(
effective_overwrite = (
overwrite if overwrite is not None else ConfigManager().config.write_note_overwrite_default
)
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)
with logfire.span(
"mcp.tool.write_note",
@@ -67,9 +67,6 @@ class PostgresSearchRepository(SearchRepositoryBase):
self._semantic_postgres_prepare_concurrency = (
self._app_config.semantic_postgres_prepare_concurrency
)
self._entity_boost_enabled = self._app_config.search_entity_boost_enabled
self._entity_boost_weight = self._app_config.search_entity_boost_weight
self._entity_boost_max_terms = self._app_config.search_entity_boost_max_terms
self._embedding_provider = embedding_provider
self._vector_dimensions = 384
self._vector_tables_initialized = False
@@ -36,7 +36,6 @@ class SearchIndexRow:
category: Optional[str] = None # observations
from_id: Optional[int] = None # relations
to_id: Optional[int] = None # relations
to_name: Optional[str] = None # relations: literal target text, set even when unresolved
relation_type: Optional[str] = None # relations
# Matched chunk text from vector search (the actual content that matched the query)
@@ -95,7 +94,6 @@ class SearchIndexRow:
else self.metadata,
"from_id": self.from_id,
"to_id": self.to_id,
"to_name": self.to_name,
"relation_type": self.relation_type,
"entity_id": self.entity_id,
"category": self.category,
@@ -40,69 +40,6 @@ BULLET_PATTERN = re.compile(r"^[\-\*]\s+")
OVERSIZED_ENTITY_VECTOR_SHARD_SIZE = 256
_SQLITE_MAX_PREPARE_WINDOW = 8
# Entity, observation, and relation rows in search_index carry ids from independent
# auto-increment sequences, so a bare id is ambiguous across row types. Every map in
# the vector/hybrid retrieval path must key rows by (type, id) to avoid collisions.
type SearchIndexKey = tuple[str, int]
# --- Entity-aware ranking boost (#951) ---
# Match word tokens (allowing internal apostrophes/hyphens) so we can inspect
# their capitalization to detect proper-noun-like query terms.
_ENTITY_TERM_TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z'\-]*")
# Common capitalized sentence-starters and interrogatives that look like proper
# nouns but are not entity references. Kept lowercase for case-insensitive checks.
# Intentionally small: a candidate term only boosts a row when it actually matches
# that row's title/relation names, so a stray non-entity term simply does nothing.
_ENTITY_TERM_STOPWORDS = frozenset(
{
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"but",
"by",
"do",
"does",
"for",
"from",
"has",
"have",
"how",
"i",
"in",
"is",
"it",
"of",
"on",
"or",
"the",
"their",
"they",
"this",
"to",
"was",
"we",
"were",
"what",
"when",
"where",
"which",
"who",
"whom",
"whose",
"why",
"will",
"with",
"you",
"your",
}
)
@dataclass
class VectorSyncBatchResult:
@@ -224,13 +161,6 @@ class SearchRepositoryBase(ABC):
_vector_dimensions: int
_vector_tables_initialized: bool
# Entity-aware ranking boost (#951). Defaults keep the feature off for any
# subclass or test double that does not explicitly configure it. Concrete
# backends overwrite these from BasicMemoryConfig in their __init__.
_entity_boost_enabled: bool = False
_entity_boost_weight: float = 0.0
_entity_boost_max_terms: int = 1
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
"""Initialize with session maker and project_id filter.
@@ -1927,7 +1857,7 @@ class SearchRepositoryBase(ABC):
# ------------------------------------------------------------------
@staticmethod
def _parse_chunk_key(chunk_key: str) -> SearchIndexKey:
def _parse_chunk_key(chunk_key: str) -> tuple[str, int]:
"""Parse a chunk_key like 'observation:5:0' into (type, search_index_id)."""
parts = chunk_key.split(":")
return parts[0], int(parts[1])
@@ -2002,27 +1932,26 @@ class SearchRepositoryBase(ABC):
hydrate_start = time.perf_counter()
# Build per-search_index_row similarity scores from chunk-level results.
# Each chunk_key encodes the search_index row type and id; keep both as the
# key because different row types can share the same numeric id (#982).
# Each chunk_key encodes the search_index row type and id.
# Track the best similarity per row (for ranking) and all chunks (for context).
similarity_by_si_key: dict[SearchIndexKey, float] = {}
chunks_by_si_key: dict[SearchIndexKey, list[tuple[float, str]]] = {}
similarity_by_si_id: dict[int, float] = {}
chunks_by_si_id: dict[int, list[tuple[float, str]]] = {}
for row in vector_rows:
chunk_key = row.get("chunk_key", "")
distance = float(row["best_distance"])
similarity = self._distance_to_similarity(distance)
chunk_text = row.get("chunk_text", "")
try:
si_key = self._parse_chunk_key(chunk_key)
_, si_id = self._parse_chunk_key(chunk_key)
except (ValueError, IndexError):
# Fallback: group by entity_id for chunks without parseable keys
continue
current = similarity_by_si_key.get(si_key)
current = similarity_by_si_id.get(si_id)
if current is None or similarity > current:
similarity_by_si_key[si_key] = similarity
chunks_by_si_key.setdefault(si_key, []).append((similarity, chunk_text))
similarity_by_si_id[si_id] = similarity
chunks_by_si_id.setdefault(si_id, []).append((similarity, chunk_text))
if not similarity_by_si_key:
if not similarity_by_si_id:
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
_log_vector_summary()
return []
@@ -2033,17 +1962,16 @@ class SearchRepositoryBase(ABC):
min_similarity if min_similarity is not None else self._semantic_min_similarity
)
if effective_min_similarity > 0.0:
similarity_by_si_key = {
k: v for k, v in similarity_by_si_key.items() if v >= effective_min_similarity
similarity_by_si_id = {
k: v for k, v in similarity_by_si_id.items() if v >= effective_min_similarity
}
if not similarity_by_si_key:
if not similarity_by_si_id:
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
_log_vector_summary()
return []
# Fetch the actual search_index rows. Colliding (type, id) keys share one
# bare id, so deduplicate while preserving first-seen order.
si_ids = list(dict.fromkeys(si_id for _, si_id in similarity_by_si_key))
# Fetch the actual search_index rows
si_ids = list(similarity_by_si_id.keys())
search_index_rows = await self._fetch_search_index_rows_by_ids(si_ids)
# Apply optional filters if requested
@@ -2075,14 +2003,16 @@ class SearchRepositoryBase(ABC):
limit=VECTOR_FILTER_SCAN_LIMIT,
offset=0,
)
# Use (type, id) tuples to avoid collisions between different
# Use (id, type) tuples to avoid collisions between different
# search_index row types that share the same auto-increment id.
allowed_keys = {(row.type, row.id) for row in filtered_rows if row.id is not None}
search_index_rows = {k: v for k, v in search_index_rows.items() if k in allowed_keys}
allowed_keys = {(row.id, row.type) for row in filtered_rows if row.id is not None}
search_index_rows = {
k: v for k, v in search_index_rows.items() if (v.id, v.type) in allowed_keys
}
ranked_rows: list[SearchIndexRow] = []
for si_key, similarity in similarity_by_si_key.items():
row = search_index_rows.get(si_key)
for si_id, similarity in similarity_by_si_id.items():
row = search_index_rows.get(si_id)
if row is None:
continue
@@ -2092,7 +2022,7 @@ class SearchRepositoryBase(ABC):
if content_snippet and len(content_snippet) <= SMALL_NOTE_CONTENT_LIMIT:
matched_chunk_text = content_snippet
else:
si_chunks = chunks_by_si_key.get(si_key, [])
si_chunks = chunks_by_si_id.get(si_id, [])
si_chunks.sort(key=lambda c: c[0], reverse=True)
top_texts = [text for _, text in si_chunks[:TOP_CHUNKS_PER_RESULT]]
matched_chunk_text = "\n---\n".join(top_texts) if top_texts else None
@@ -2158,12 +2088,8 @@ class SearchRepositoryBase(ABC):
async def _fetch_search_index_rows_by_ids(
self, row_ids: list[int]
) -> dict[SearchIndexKey, SearchIndexRow]:
"""Fetch search_index rows by id, keyed by (type, id) to disambiguate types.
A bare id can match one row per type (independent id sequences), so the
result must carry every matching row rather than letting one clobber another.
"""
) -> dict[int, SearchIndexRow]:
"""Fetch search_index rows by their primary key (id), any type."""
if not row_ids:
return {}
placeholders = ",".join(f":id_{idx}" for idx in range(len(row_ids)))
@@ -2180,11 +2106,11 @@ class SearchRepositoryBase(ABC):
WHERE project_id = :project_id
AND id IN ({placeholders})
"""
result: dict[SearchIndexKey, SearchIndexRow] = {}
result: dict[int, SearchIndexRow] = {}
async with db.scoped_session(self.session_maker) as session:
row_result = await session.execute(text(sql), params)
for row in row_result.fetchall():
result[(row.type, row.id)] = SearchIndexRow(
result[row.id] = SearchIndexRow(
project_id=self.project_id,
id=row.id,
title=row.title,
@@ -2212,105 +2138,6 @@ class SearchRepositoryBase(ABC):
# Shared semantic search: hybrid score-based fusion
# ------------------------------------------------------------------
# --- Entity-aware ranking boost (#951) ---
@staticmethod
def _extract_query_entity_terms(search_text: Optional[str]) -> set[str]:
"""Extract candidate entity (proper-noun) terms from a query string.
Heuristic, lexical only (no model inference): a token is a candidate entity
term when it is title-cased or all-caps and is not a common stopword. The
result is lowercased so downstream matching is case-insensitive.
Examples:
"What are Joanna's hobbies?" -> {"joanna"}
"Who is Anthony?" -> {"anthony"}
"Deborah and Jolene" -> {"deborah", "jolene"}
"what is the weather" -> set() (no proper nouns)
"""
if not search_text:
return set()
terms: set[str] = set()
for match in _ENTITY_TERM_TOKEN_PATTERN.finditer(search_text):
token = match.group(0)
# Trigger: token begins with an uppercase letter (Title-Case or ALL-CAPS).
# Why: proper nouns and named entities are conventionally capitalized; this
# is the cheapest reliable signal without a NER model.
# Outcome: lowercase, non-capitalized words are ignored as generic terms.
if not token[0].isupper():
continue
normalized = token.lower()
# Strip a trailing possessive so "Joanna's" matches the entity "Joanna".
if normalized.endswith("'s"):
normalized = normalized[:-2]
if normalized in _ENTITY_TERM_STOPWORDS:
continue
# Single characters (e.g. a stray "I") carry no entity signal.
if len(normalized) < 2:
continue
terms.add(normalized)
return terms
@staticmethod
def _row_entity_match_count(row: SearchIndexRow, entity_terms: set[str]) -> int:
"""Count distinct query entity terms that a candidate row references.
Matches against the row's own entity name (title) and the names embedded in
a relation row's title (``"From -> To"``). These are the fields where Basic
Memory's first-class entity names surface, so a match here is strong evidence
the candidate is about the queried entity rather than a same-topic document.
"""
if not entity_terms:
return 0
haystack_parts = [row.title or ""]
# Relation rows encode linked entity names in their title ("From -> To");
# the relation_type itself is not an entity name, so it is excluded.
haystack = " ".join(part for part in haystack_parts if part)
if not haystack:
return 0
haystack_tokens: set[str] = set()
for match in _ENTITY_TERM_TOKEN_PATTERN.finditer(haystack):
token = match.group(0).lower()
# Mirror the query-side possessive stripping so a doc titled
# "Joanna's Hobbies" matches the query entity term "joanna".
if token.endswith("'s"):
token = token[:-2]
haystack_tokens.add(token)
return len(entity_terms & haystack_tokens)
def _apply_entity_boost(
self,
fused_scores: dict[SearchIndexKey, float],
rows_by_key: dict[SearchIndexKey, SearchIndexRow],
entity_terms: set[str],
) -> dict[SearchIndexKey, float]:
"""Multiply fused scores by a per-matched-term bonus for entity-matching rows.
Trigger: entity boosting is enabled and the query contains proper-noun terms.
Why: a candidate whose entity/relation names contain a queried proper noun is a
stronger answer than a generic same-topic document (#951 cross-conversation
confusion).
Outcome: ``score * (1 + weight * min(matches, max_terms))``. Rows that match no
query entity term are returned unchanged, so relative order among non-matching
rows is preserved.
"""
if not self._entity_boost_enabled or not entity_terms or self._entity_boost_weight <= 0:
return fused_scores
boosted: dict[SearchIndexKey, float] = {}
for row_key, score in fused_scores.items():
row = rows_by_key.get(row_key)
matches = self._row_entity_match_count(row, entity_terms) if row is not None else 0
if matches <= 0:
boosted[row_key] = score
continue
capped_matches = min(matches, self._entity_boost_max_terms)
boosted[row_key] = score * (1.0 + self._entity_boost_weight * capped_matches)
return boosted
async def _search_hybrid(
self,
*,
@@ -2329,7 +2156,7 @@ class SearchRepositoryBase(ABC):
) -> List[SearchIndexRow]:
"""Fuse FTS and vector results using score-based fusion.
Uses the search_index (type, id) pair as the fusion key. The formula
Uses search_index row id as the fusion key. The formula
``max(vec, fts) + FUSION_BONUS * min(vec, fts)`` preserves
the dominant signal and rewards dual-source agreement.
"""
@@ -2372,19 +2199,17 @@ class SearchRepositoryBase(ABC):
vector_ms = (time.perf_counter() - vector_start) * 1000
fusion_start = time.perf_counter()
# --- Score-based fusion keyed on (type, id) ---
# A bare row id collides across row types (independent id sequences), so
# fusion must key on (type, id) or distinct rows would merge (#982).
# --- Score-based fusion keyed on search_index row id ---
# FTS scores are normalized to [0, 1] (BM25 is unbounded).
# Vector scores are used raw — already calibrated [0, 1] by _distance_to_similarity().
rows_by_key: dict[SearchIndexKey, SearchIndexRow] = {}
rows_by_id: dict[int, SearchIndexRow] = {}
# Normalize FTS scores to [0, 1] — handles both SQLite (negative bm25)
# and Postgres (positive ts_rank) by using absolute values
fts_abs = [abs(row.score or 0.0) for row in fts_results]
fts_max = max(fts_abs) if fts_abs else 1.0
fts_scores: dict[SearchIndexKey, float] = {}
fts_scores: dict[int, float] = {}
for row in fts_results:
if row.id is None:
continue
@@ -2392,41 +2217,32 @@ class SearchRepositoryBase(ABC):
# Gate: FTS scores below threshold contribute zero
if norm < FTS_GATE_THRESHOLD:
norm = 0.0
fts_scores[(row.type, row.id)] = norm
rows_by_key[(row.type, row.id)] = row
fts_scores[row.id] = norm
rows_by_id[row.id] = row
vec_scores: dict[SearchIndexKey, float] = {}
vec_scores: dict[int, float] = {}
for row in vector_results:
if row.id is None:
continue
# Trigger: no re-normalization by vec_max
# Why: vector similarity is already calibrated [0, 1]; re-normalizing
# inflates weak matches when the entire result set is mediocre
vec_scores[(row.type, row.id)] = row.score or 0.0
rows_by_key[(row.type, row.id)] = row
vec_scores[row.id] = row.score or 0.0
rows_by_id[row.id] = row
# Fuse: max(v, f) + FUSION_BONUS * min(v, f)
# Preserves the dominant signal; bonus rewards dual-source agreement.
# Output range: [0, 1.3] for dual-source, [0, 1.0] for single-source.
fused_scores: dict[SearchIndexKey, float] = {}
for row_key in fts_scores.keys() | vec_scores.keys():
v = vec_scores.get(row_key, 0.0)
f = fts_scores.get(row_key, 0.0)
fused_scores[row_key] = max(v, f) + FUSION_BONUS * min(v, f)
# Entity-aware ranking boost (#951): runs over the full fused candidate set
# before the limit/offset cut, so a boosted entity-matching candidate can be
# promoted into the returned window. No-op when the feature is disabled or the
# query contains no proper-noun terms, preserving the existing ordering.
entity_terms = (
self._extract_query_entity_terms(query_text) if self._entity_boost_enabled else set()
)
fused_scores = self._apply_entity_boost(fused_scores, rows_by_key, entity_terms)
fused_scores: dict[int, float] = {}
for row_id in fts_scores.keys() | vec_scores.keys():
v = vec_scores.get(row_id, 0.0)
f = fts_scores.get(row_id, 0.0)
fused_scores[row_id] = max(v, f) + FUSION_BONUS * min(v, f)
ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True)
output: list[SearchIndexRow] = []
for row_key, fused_score in ranked[offset : offset + limit]:
row = rows_by_key[row_key]
for row_id, fused_score in ranked[offset : offset + limit]:
row = rows_by_id[row_id]
# Trigger: FTS-only results have no matched_chunk_text from vector search.
# Why: without chunk text, API falls back to truncated content, losing answer text.
# Outcome: FTS-only results get full content_snippet as matched_chunk.
@@ -55,9 +55,6 @@ class SQLiteSearchRepository(SearchRepositoryBase):
self._semantic_embedding_sync_batch_size = (
self._app_config.semantic_embedding_sync_batch_size
)
self._entity_boost_enabled = self._app_config.search_entity_boost_enabled
self._entity_boost_weight = self._app_config.search_entity_boost_weight
self._entity_boost_max_terms = self._app_config.search_entity_boost_max_terms
self._embedding_provider = embedding_provider
self._sqlite_vec_load_lock = asyncio.Lock()
self._sqlite_prepare_write_lock = asyncio.Lock()
+1 -4
View File
@@ -19,6 +19,7 @@ from pathlib import Path
from typing import List, Optional, Annotated, Dict
from annotated_types import MinLen, MaxLen
from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
@@ -91,10 +92,6 @@ def parse_timeframe(timeframe: str) -> datetime:
parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
"""
# Deferred: dateparser costs ~0.13s to import; schemas load on every CLI
# start, but timeframe parsing only happens per request (#886).
from dateparser import parse
if timeframe.lower() == "today":
# For "today", return 1 day ago to ensure we capture recent activity across timezones
# This handles the case where client and server are in different timezones
-3
View File
@@ -155,9 +155,6 @@ class RelationSummary(BaseModel):
from_entity_id: Optional[int] = None
from_entity_external_id: Optional[str] = None
to_entity: Optional[str] = None
# Literal target text from the markdown; present even when the relation is
# an unresolved forward reference (to_entity is None until the target exists)
to_name: Optional[str] = None
to_entity_id: Optional[int] = None
to_entity_external_id: Optional[str] = None
created_at: Annotated[
+2 -38
View File
@@ -18,7 +18,6 @@ from basic_memory.repository.search_repository import SearchRepository, SearchIn
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
from basic_memory.schemas.search import SearchItemType
from basic_memory.utils import generate_permalink
from basic_memory.workspace_context import workspace_slug_for_canonical_permalinks
if TYPE_CHECKING:
from basic_memory.services.link_resolver import LinkResolver
@@ -37,7 +36,6 @@ class ContextResultRow:
from_id: Optional[int] = None
to_id: Optional[int] = None
relation_type: Optional[str] = None
to_name: Optional[str] = None
content: Optional[str] = None
category: Optional[str] = None
entity_id: Optional[int] = None
@@ -145,29 +143,6 @@ class ContextService:
primary = await self.search_repository.search(
permalink_match=normalized_path, limit=fetch_limit, offset=offset
)
# Trigger: a workspace-qualified pattern matched nothing while a
# workspace permalink context is active.
# Why: rows written before workspace canonicalization (or via
# clients that didn't forward workspace headers) store
# project-qualified permalinks; a workspace-prefixed pattern
# can never match those legacy rows (#957).
# Outcome: retry once with the workspace prefix stripped so the
# pattern matches the index form the rows actually carry.
if not primary:
workspace_slug = workspace_slug_for_canonical_permalinks()
ws_prefix = f"{workspace_slug}/" if workspace_slug else None
if ws_prefix and normalized_path.startswith(ws_prefix):
fallback_path = normalized_path.removeprefix(ws_prefix)
logger.debug(
f"Pattern search fallback without workspace prefix: "
f"'{fallback_path}'"
)
primary = await self.search_repository.search(
permalink_match=fallback_path,
limit=fetch_limit,
offset=offset,
)
else:
normalized_path = generate_permalink(path, split_extension=False)
logger.debug(f"Direct lookup for '{normalized_path}'")
@@ -417,7 +392,6 @@ class ContextService:
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
to_name=row.to_name,
content=row.content,
category=row.category,
entity_id=row.entity_id,
@@ -452,7 +426,6 @@ class ContextService:
CAST(NULL AS INTEGER) as from_id,
CAST(NULL AS INTEGER) as to_id,
CAST(NULL AS TEXT) as relation_type,
CAST(NULL AS TEXT) as to_name,
CAST(NULL AS TEXT) as content,
CAST(NULL AS TEXT) as category,
CAST(NULL AS INTEGER) as entity_id,
@@ -505,10 +478,6 @@ class ContextService:
WHEN step_type = 1 THEN r.relation_type
ELSE NULL
END as relation_type,
CASE
WHEN step_type = 1 THEN r.to_name
ELSE NULL
END as to_name,
CAST(NULL AS TEXT) as content,
CAST(NULL AS TEXT) as category,
CAST(NULL AS INTEGER) as entity_id,
@@ -572,7 +541,6 @@ class ContextService:
from_id,
to_id,
relation_type,
to_name,
content,
category,
entity_id,
@@ -582,7 +550,7 @@ class ContextService:
FROM entity_graph
WHERE depth > 0
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
relation_type, to_name, content, category, entity_id, root_id, created_at
relation_type, content, category, entity_id, root_id, created_at
ORDER BY depth, type, id
LIMIT :max_results
""")
@@ -611,7 +579,6 @@ class ContextService:
NULL as from_id,
NULL as to_id,
NULL as relation_type,
NULL as to_name,
NULL as content,
NULL as category,
NULL as entity_id,
@@ -639,7 +606,6 @@ class ContextService:
r.from_id,
r.to_id,
r.relation_type,
r.to_name,
NULL as content,
NULL as category,
NULL as entity_id,
@@ -678,7 +644,6 @@ class ContextService:
NULL as from_id,
NULL as to_id,
NULL as relation_type,
NULL as to_name,
NULL as content,
NULL as category,
NULL as entity_id,
@@ -712,7 +677,6 @@ class ContextService:
from_id,
to_id,
relation_type,
to_name,
content,
category,
entity_id,
@@ -722,7 +686,7 @@ class ContextService:
FROM entity_graph
WHERE depth > 0
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
relation_type, to_name, content, category, entity_id, root_id, created_at
relation_type, content, category, entity_id, root_id, created_at
ORDER BY depth, type, id
LIMIT :max_results
""")
@@ -256,41 +256,6 @@ class ProjectService:
await self.repository.set_as_default(created_project.id)
self.config_manager.set_default_project(name)
logger.info(f"Project '{name}' set as default")
else:
config_default = self.config_manager.default_project
if config_default is not None:
db_default_project = await self.repository.get_by_name(config_default)
if db_default_project is None:
# Trigger: config names a default project that has no database row
# (the fresh-config wedge from issue #974).
# Why: synchronize_projects treats an existing database default as
# authoritative, so a surviving default must win over promoting
# the just-added project. set_default_project raises for names
# absent from config — and reconciliation deletes such rows —
# so a database default unknown to config cannot be repointed to.
# Outcome: config is repointed at a usable database default when one
# exists; otherwise the added project becomes the default.
db_default = await self.repository.get_default_project()
if (
db_default is not None
and self.config_manager.get_project(db_default.name)[0] is not None
):
self.config_manager.set_default_project(db_default.name)
logger.info(
"Repointed config default from missing '%s' at existing "
"database default '%s'",
config_default,
db_default.name,
)
else:
await self.repository.set_as_default(created_project.id)
self.config_manager.set_default_project(name)
logger.info(
"Promoted project '%s' to default because configured default '%s' "
"is missing from database",
name,
config_default,
)
logger.info(f"Project '{name}' added at {resolved_path}")
-56
View File
@@ -600,62 +600,6 @@ def strict_search_tags(v: Any) -> Any:
return v
def parse_str_list(v: Any) -> List[str]:
"""Parse a list of plain strings from various input formats.
Like parse_tags but without stripping '#' correct for type/category params
where the value is a literal identifier, not a hashtag.
Handles the four input shapes that MCP clients commonly produce:
- None []
- "note,task" ["note", "task"] (comma-split string)
- '["note","task"]' ["note", "task"] (JSON array string)
- ["note,task"] ["note", "task"] (list with comma-string element)
Non-str/list/None values are returned unchanged so Pydantic can reject them
with a clear validation error instead of silently coercing.
"""
if v is None:
return []
if isinstance(v, list):
# Trigger: a list element is not a string (e.g. [42] or ["note", 42]).
# Why: str(raw) would silently convert 42 → "42" and let invalid caller data pass
# Pydantic validation as a junk filter, producing a silent no-result search.
# Outcome: return the list unchanged so Pydantic rejects it with a clear error.
if not all(isinstance(raw, str) for raw in v if raw is not None):
return v # type: ignore[return-value]
# Trigger: a list element may itself be a comma-separated string (e.g. some MCP clients
# serialise `["note,task"]` when the caller passed `note_types="note,task"`).
# Outcome: flatten each element by splitting on commas and stripping whitespace.
return [
item.strip()
for raw in v
if raw is not None
for item in raw.split(",")
if item and item.strip()
]
if isinstance(v, str):
# Trigger: MCP clients sometimes send a JSON array string like '["note","task"]'.
# Outcome: parse it as JSON first, then recurse to handle the resulting list.
stripped = v.strip()
if stripped.startswith("[") and stripped.endswith("]"):
try:
parsed_json = json.loads(stripped)
if isinstance(parsed_json, list):
return parse_str_list(parsed_json)
except json.JSONDecodeError:
pass
# Plain comma-separated string: "note,task" → ["note", "task"]
return [item.strip() for item in v.split(",") if item and item.strip()]
# Non-str/list/None — return unchanged so Pydantic rejects with a clear error.
return v # type: ignore[return-value]
def coerce_list(v: Any) -> Any:
"""Coerce string input to list for MCP clients that serialize lists as strings."""
if v is None:
+4 -31
View File
@@ -89,27 +89,6 @@ class TestRoutingFlagsValidation:
assert "Cannot specify both --local and --cloud" in result.output
def _stub_auto_update(monkeypatch, mcp_mod) -> None:
"""Keep `bm mcp` stdio tests from running the real background auto-update.
The command starts a daemon thread before mcp_server.run; unstubbed it hits
PyPI and rewrites config.json from a background thread, leaking into later
tests in the same process (#940's KeyError flake on test_mcp_sse_forces_local).
"""
from basic_memory.cli.auto_update import AutoUpdateResult, AutoUpdateStatus, InstallSource
def skipped_auto_update(**kwargs) -> AutoUpdateResult:
return AutoUpdateResult(
status=AutoUpdateStatus.SKIPPED,
source=InstallSource.UNKNOWN,
checked=False,
update_available=False,
updated=False,
)
monkeypatch.setattr(mcp_mod, "run_auto_update", skipped_auto_update)
class TestMcpCommandRouting:
"""Tests that MCP routing varies by transport."""
@@ -130,10 +109,8 @@ class TestMcpCommandRouting:
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
_stub_auto_update(monkeypatch, mcp_mod)
result = runner.invoke(cli_app, ["mcp"]) # default transport is stdio
assert result.exit_code == 0, result.output
runner.invoke(cli_app, ["mcp"]) # default transport is stdio
# Command should not have set these vars
assert env_at_run["FORCE_LOCAL"] is None
@@ -155,10 +132,8 @@ class TestMcpCommandRouting:
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
_stub_auto_update(monkeypatch, mcp_mod)
result = runner.invoke(cli_app, ["mcp"])
assert result.exit_code == 0, result.output
runner.invoke(cli_app, ["mcp"])
# Externally-set vars should be preserved
assert env_at_run["FORCE_CLOUD"] == "true"
@@ -178,8 +153,7 @@ class TestMcpCommandRouting:
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
result = runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
assert result.exit_code == 0, result.output
runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
assert env_at_run["FORCE_LOCAL"] == "true"
assert env_at_run["EXPLICIT"] == "true"
@@ -198,8 +172,7 @@ class TestMcpCommandRouting:
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
result = runner.invoke(cli_app, ["mcp", "--transport", "sse"])
assert result.exit_code == 0, result.output
runner.invoke(cli_app, ["mcp", "--transport", "sse"])
assert env_at_run["FORCE_LOCAL"] == "true"
assert env_at_run["EXPLICIT"] == "true"
@@ -591,35 +591,36 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_
@pytest.mark.asyncio
async def test_create_project_workspace_without_credentials_fails_fast(
async def test_create_project_accepts_workspace_in_local_mode(
mcp_server, app, test_project, tmp_path
):
"""A workspace selector without cloud credentials fails fast — no local create (#954).
"""Passing workspace via the MCP wire is accepted by the tool schema and
does not break the local create path.
The previous contract treated workspace as a local no-op, which was the bug:
a caller asking for a cloud team workspace got a silent local project. The
tool schema still accepts the parameter (this test exercises it over the
MCP wire), but the create must error with auth guidance and leave no
project behind.
In local mode there is no cloud factory installed, so workspace is a no-op:
the request lands on the ASGI transport which has no workspace concept. This
test guards the schema so a future change can't accidentally drop the parameter.
"""
from fastmcp.exceptions import ToolError
async with Client(mcp_server) as client:
with pytest.raises(ToolError, match="cloud workspace was requested"):
await client.call_tool(
"create_memory_project",
{
"project_name": "ws-local-test",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-ws-local-test"
),
"workspace": "team-paul",
},
)
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": "ws-local-test",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-ws-local-test"
),
"workspace": "team-paul",
},
)
assert len(create_result.content) == 1
create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "" in create_text
assert "ws-local-test" in create_text
# The failed create must not leave a local project behind
list_result = await client.call_tool("list_memory_projects", {})
assert "ws-local-test" not in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "ws-local-test" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
-107
View File
@@ -6,7 +6,6 @@ from pathlib import Path
import pytest
from httpx import AsyncClient
from basic_memory.config import ProjectEntry
from basic_memory.models import Project
from basic_memory.schemas.project_info import ProjectItem, ProjectStatusResponse
from basic_memory.schemas.v2 import ProjectResolveResponse
@@ -55,46 +54,6 @@ async def test_get_project_by_id_not_found(client: AsyncClient, v2_projects_url)
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_add_project_response_reflects_promoted_default(
client: AsyncClient,
v2_projects_url,
app_config,
config_manager,
config_home,
project_repository,
):
"""Regression #974/#985: POST response should echo persisted default promotion."""
main_home = config_home / "basic-memory"
main_home.mkdir(parents=True, exist_ok=True)
qa_path = config_home / "qa-notes"
qa_path.mkdir(parents=True, exist_ok=True)
fresh_config = app_config.model_copy(
update={
"projects": {"main": ProjectEntry(path=str(main_home))},
"default_project": "main",
}
)
config_manager.save_config(fresh_config)
for project in await project_repository.find_all():
await project_repository.delete(project.id)
response = await client.post(
f"{v2_projects_url}/",
json={"name": "qa", "path": str(qa_path), "set_default": False},
)
assert response.status_code == 201
status_response = ProjectStatusResponse.model_validate(response.json())
assert status_response.status == "success"
assert status_response.default is True
new_project = _project_item(status_response.new_project)
assert new_project.name == "qa"
assert new_project.is_default is True
@pytest.mark.asyncio
async def test_update_project_path_by_id(
client: AsyncClient, test_project: Project, v2_projects_url
@@ -178,37 +137,6 @@ async def test_set_default_project_by_id(
assert old_project.is_default is False
@pytest.mark.asyncio
async def test_set_default_project_when_none_is_set(
client: AsyncClient, test_project: Project, v2_projects_url, project_repository
):
"""Regression for #975: setting a default must succeed when none is set.
This is the bootstrap/recovery case: `bm project default <name>` is exactly
the command reached for when no default exists, so the endpoint must not 404.
"""
# Clear any existing default so no row has is_default set.
await project_repository.update(test_project.id, {"is_default": None})
assert await project_repository.get_default_project() is None
response = await client.put(f"{v2_projects_url}/{test_project.external_id}/default")
assert response.status_code == 200
status_response = ProjectStatusResponse.model_validate(response.json())
assert status_response.status == "success"
assert status_response.default is True
# No previous default existed, so old_project must be None.
assert status_response.old_project is None
new_project = _project_item(status_response.new_project)
assert new_project.external_id == test_project.external_id
assert new_project.is_default is True
# A follow-up read-back must now return the newly set default.
default_project = await project_repository.get_default_project()
assert default_project is not None
assert default_project.external_id == test_project.external_id
@pytest.mark.asyncio
async def test_set_default_project_not_found(client: AsyncClient, v2_projects_url):
"""Test setting a non-existent project as default returns 404."""
@@ -445,41 +373,6 @@ async def test_resolve_project_not_found(client: AsyncClient, v2_projects_url):
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_resolve_project_not_found_fresh_install_names_setup_command(
client: AsyncClient, v2_projects_url, project_repository
):
"""#974 follow-up: a fresh install fails its first read with a bare not-found.
config.json bootstraps a "main" default before any reconciliation has created
database rows (the one-shot CLI never runs the server lifespan), so resolving
the configured default 404s. With an empty projects table the error must point
at first-run setup instead of reading like a broken install.
"""
for project in await project_repository.find_all():
await project_repository.delete(project.id)
response = await client.post(f"{v2_projects_url}/resolve", json={"identifier": "main"})
assert response.status_code == 404
detail = response.json()["detail"]
assert detail.startswith("Project not found: 'main'")
assert "basic-memory project add" in detail
@pytest.mark.asyncio
async def test_resolve_project_not_found_with_projects_keeps_plain_message(
client: AsyncClient, test_project: Project, v2_projects_url
):
"""A miss against a populated projects table stays a plain not-found."""
response = await client.post(
f"{v2_projects_url}/resolve", json={"identifier": "nonexistent-project"}
)
assert response.status_code == 404
assert response.json()["detail"] == "Project not found: 'nonexistent-project'"
@pytest.mark.asyncio
async def test_resolve_project_empty_identifier(client: AsyncClient, v2_projects_url):
"""Test resolving with empty identifier returns 422."""
-15
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from io import StringIO
from typing import Any, cast
@@ -16,7 +15,6 @@ from basic_memory.cli.auto_update import (
InstallSource,
_check_homebrew_update_available,
_is_interactive_session,
_preload_lazy_console_modules,
detect_install_source,
maybe_run_periodic_auto_update,
run_auto_update,
@@ -161,19 +159,6 @@ def test_check_homebrew_update_available_exit_code_0_means_up_to_date(monkeypatc
assert is_outdated is False
def test_preload_lazy_console_modules_imports_deferred_modules(monkeypatch):
# Regression: the in-place upgrade deletes the running install's files, so
# any module rich/typer defers until print time must already be loaded or
# the final status message crashes with ModuleNotFoundError.
monkeypatch.delitem(sys.modules, "rich._emoji_codes", raising=False)
monkeypatch.delitem(sys.modules, "typer.rich_utils", raising=False)
_preload_lazy_console_modules()
assert "rich._emoji_codes" in sys.modules
assert "typer.rich_utils" in sys.modules
def test_homebrew_outdated_triggers_upgrade(monkeypatch, tmp_path):
config = _base_config(tmp_path)
manager = StubConfigManager(config)
+10 -10
View File
@@ -219,8 +219,8 @@ def test_setup_does_not_partially_write_generated_files_when_target_exists(
mock_seed.assert_not_awaited()
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
async def test_seed_project_update_schemas_skips_existing_notes_by_default(
mock_write: AsyncMock,
mock_search: AsyncMock,
@@ -235,8 +235,8 @@ async def test_seed_project_update_schemas_skips_existing_notes_by_default(
mock_write.assert_not_awaited()
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
async def test_seed_project_update_schemas_refreshes_existing_notes(
mock_write: AsyncMock,
mock_search: AsyncMock,
@@ -327,8 +327,8 @@ def test_agent_schema_command_writes_schema(tmp_path: Path) -> None:
assert schema["title"] == "AgentSynthesis"
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
def test_publish_command_upserts_project_update_note(
mock_write: AsyncMock,
mock_search: AsyncMock,
@@ -401,8 +401,8 @@ def test_publish_command_upserts_project_update_note(
)
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
def test_publish_command_preserves_existing_note_path_for_idempotency_match(
mock_write: AsyncMock,
mock_search: AsyncMock,
@@ -464,8 +464,8 @@ def test_publish_command_preserves_existing_note_path_for_idempotency_match(
assert kwargs["directory"] == "custom/project-updates"
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
def test_publish_command_uses_project_id_without_workspace_qualifying_project(
mock_write: AsyncMock,
mock_search: AsyncMock,
-40
View File
@@ -86,46 +86,6 @@ def test_bm_version_does_not_import_heavy_modules():
)
def test_bm_cli_import_does_not_load_heavy_stack():
"""Regression test (#886): registering all CLI commands must stay lightweight.
Importing basic_memory.cli.main with a normal argv registers every command
module. None of them may pull FastAPI, the API app, SQLAlchemy/Alembic, the
MCP tool stack, or the markdown/services layers in at import time those
must load lazily when a command actually runs.
"""
heavy_modules = (
"fastapi",
"sqlalchemy",
"alembic",
"fastmcp",
"mcp",
"basic_memory.api.app",
"basic_memory.db",
"basic_memory.markdown",
"basic_memory.mcp.tools",
"basic_memory.services",
)
check_script = (
"import sys; "
"sys.argv = ['bm', 'tool', 'search-notes', '--help']; "
"import basic_memory.cli.main; "
f"heavy = [m for m in {heavy_modules!r} if m in sys.modules]; "
"print(','.join(heavy) if heavy else 'CLEAN')"
)
result = subprocess.run(
["uv", "run", "python", "-c", check_script],
capture_output=True,
text=True,
timeout=20,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "CLEAN" in result.stdout, (
f"Heavy modules loaded during CLI import: {result.stdout.strip()}"
)
def test_bm_help_does_not_import_api_app():
"""Regression test: 'bm --help' must not build the FastAPI app graph."""
check_script = (
+11 -11
View File
@@ -87,7 +87,7 @@ def _mock_config_manager():
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -108,7 +108,7 @@ def test_validate_renders_table(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -123,7 +123,7 @@ def test_validate_strict_exits_on_errors(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value={"error": "No notes found of type 'person'"},
)
@@ -139,7 +139,7 @@ def test_validate_error_response(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -159,7 +159,7 @@ def test_validate_identifier_heuristic(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.schema.mcp_schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -179,7 +179,7 @@ def test_infer_renders_table(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.schema.mcp_schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -195,7 +195,7 @@ def test_infer_threshold_passthrough(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.schema.mcp_schema_infer",
new_callable=AsyncMock,
return_value={"error": "No schema pattern found for 'person' (threshold: 25%)"},
)
@@ -211,7 +211,7 @@ def test_infer_error_response(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.schema.mcp_schema_infer",
new_callable=AsyncMock,
return_value={
"note_type": "person",
@@ -238,7 +238,7 @@ def test_infer_zero_notes(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_diff",
"basic_memory.cli.commands.schema.mcp_schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_WITH_DRIFT,
)
@@ -259,7 +259,7 @@ def test_diff_renders_drift(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_diff",
"basic_memory.cli.commands.schema.mcp_schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_NO_DRIFT,
)
@@ -275,7 +275,7 @@ def test_diff_no_drift(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_diff",
"basic_memory.cli.commands.schema.mcp_schema_diff",
new_callable=AsyncMock,
return_value={"error": "No schema found for type 'person'"},
)
+33 -33
View File
@@ -98,7 +98,7 @@ SEARCH_RESULT = {
@patch(
"basic_memory.mcp.tools.write_note",
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -129,7 +129,7 @@ def test_write_note_json_output(mock_mcp_write):
@patch(
"basic_memory.mcp.tools.write_note",
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -161,7 +161,7 @@ def test_write_note_project_id_passthrough(mock_mcp_write):
@patch(
"basic_memory.mcp.tools.write_note",
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -190,7 +190,7 @@ def test_write_note_with_tags(mock_mcp_write):
@patch(
"basic_memory.mcp.tools.write_note",
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -217,7 +217,7 @@ def test_write_note_type_passthrough(mock_mcp_write):
@patch(
"basic_memory.mcp.tools.write_note",
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -245,7 +245,7 @@ def test_write_note_type_defaults_to_note(mock_mcp_write):
@patch(
"basic_memory.mcp.tools.read_note",
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
@@ -267,7 +267,7 @@ def test_read_note_json_output(mock_mcp_read):
@patch(
"basic_memory.mcp.tools.read_note",
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
@@ -286,7 +286,7 @@ def test_read_note_include_frontmatter(mock_mcp_read):
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
@@ -307,7 +307,7 @@ def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_DIRECTORY_RESULT,
)
@@ -325,7 +325,7 @@ def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -349,7 +349,7 @@ def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -371,7 +371,7 @@ def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -398,7 +398,7 @@ def test_delete_note_directory_partial_failure_exits_nonzero(
@patch(
"basic_memory.mcp.tools.delete_note",
"basic_memory.cli.commands.tool.mcp_delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
@@ -418,7 +418,7 @@ def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.mcp.tools.edit_note",
"basic_memory.cli.commands.tool.mcp_edit_note",
new_callable=AsyncMock,
return_value=EDIT_NOTE_RESULT,
)
@@ -446,7 +446,7 @@ def test_edit_note_json_output(mock_mcp_edit):
@patch(
"basic_memory.mcp.tools.edit_note",
"basic_memory.cli.commands.tool.mcp_edit_note",
new_callable=AsyncMock,
return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"},
)
@@ -472,7 +472,7 @@ def test_edit_note_error_response(mock_mcp_edit):
@patch(
"basic_memory.mcp.tools.build_context",
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
@@ -491,7 +491,7 @@ def test_build_context_json_output(mock_build_ctx):
@patch(
"basic_memory.mcp.tools.build_context",
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
@@ -526,7 +526,7 @@ def test_build_context_with_options(mock_build_ctx):
@patch(
"basic_memory.mcp.tools.recent_activity",
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
@@ -548,7 +548,7 @@ def test_recent_activity_json_output(mock_mcp_recent):
@patch(
"basic_memory.mcp.tools.recent_activity",
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
@@ -566,7 +566,7 @@ def test_recent_activity_pagination(mock_mcp_recent):
@patch(
"basic_memory.mcp.tools.recent_activity",
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=[],
)
@@ -586,7 +586,7 @@ def test_recent_activity_empty(mock_mcp_recent):
@patch(
"basic_memory.mcp.tools.search_notes",
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -606,7 +606,7 @@ def test_search_notes_json_output(mock_mcp_search):
@patch(
"basic_memory.mcp.tools.search_notes",
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -622,7 +622,7 @@ def test_search_notes_with_meta_filter(mock_mcp_search):
@patch(
"basic_memory.mcp.tools.search_notes",
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -638,7 +638,7 @@ def test_search_notes_permalink_mode(mock_mcp_search):
@patch(
"basic_memory.mcp.tools.search_notes",
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value="Error: search failed",
)
@@ -699,7 +699,7 @@ SCHEMA_VALIDATE_RESULT = {
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
@@ -719,7 +719,7 @@ def test_schema_validate_json_output(mock_mcp):
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
@@ -736,7 +736,7 @@ def test_schema_validate_identifier_heuristic(mock_mcp):
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value={"error": "No notes found of type 'person'"},
)
@@ -769,7 +769,7 @@ SCHEMA_INFER_RESULT = {
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.tool.mcp_schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
@@ -789,7 +789,7 @@ def test_schema_infer_json_output(mock_mcp):
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.tool.mcp_schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
@@ -818,7 +818,7 @@ SCHEMA_DIFF_RESULT = {
@patch(
"basic_memory.mcp.tools.schema_diff",
"basic_memory.cli.commands.tool.mcp_schema_diff",
new_callable=AsyncMock,
return_value=SCHEMA_DIFF_RESULT,
)
@@ -860,7 +860,7 @@ LIST_PROJECTS_RESULT = {
@patch(
"basic_memory.mcp.tools.list_memory_projects",
"basic_memory.cli.commands.tool.mcp_list_projects",
new_callable=AsyncMock,
return_value=LIST_PROJECTS_RESULT,
)
@@ -898,7 +898,7 @@ LIST_WORKSPACES_RESULT = {
@patch(
"basic_memory.mcp.tools.list_workspaces",
"basic_memory.cli.commands.tool.mcp_list_workspaces",
new_callable=AsyncMock,
return_value=LIST_WORKSPACES_RESULT,
)
@@ -919,7 +919,7 @@ def test_list_workspaces_json_output(mock_mcp):
@patch(
"basic_memory.mcp.tools.list_workspaces",
"basic_memory.cli.commands.tool.mcp_list_workspaces",
new_callable=AsyncMock,
return_value={"workspaces": [], "count": 0},
)
+11 -20
View File
@@ -195,21 +195,15 @@ async def test_reindex_project_full_passes_force_full_to_sync_and_reports_mode(m
if "total" in kwargs:
self.tasks[task_id].total = kwargs["total"]
# _reindex imports its database/sync dependencies at call time (#886),
# so stubs target the source modules instead of db_cmd attributes.
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
monkeypatch.setattr(
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
)
monkeypatch.setattr(
"basic_memory.db.get_or_create_db",
db_cmd.db,
"get_or_create_db",
AsyncMock(return_value=(None, session_maker)),
)
monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock())
monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository)
monkeypatch.setattr(
"basic_memory.sync.sync_service.get_sync_service",
AsyncMock(return_value=sync_service),
)
monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock())
monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository)
monkeypatch.setattr(db_cmd, "get_sync_service", AsyncMock(return_value=sync_service))
monkeypatch.setattr(db_cmd, "Progress", SilentProgress)
monkeypatch.setattr(
db_cmd.console,
@@ -283,17 +277,14 @@ async def test_reindex_embeddings_only_full_passes_force_full_to_vector_reindex(
if "total" in kwargs:
self.tasks[task_id].total = kwargs["total"]
# _reindex imports its database/sync dependencies at call time (#886),
# so stubs target the source modules instead of db_cmd attributes.
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
monkeypatch.setattr(
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
)
monkeypatch.setattr(
"basic_memory.db.get_or_create_db",
db_cmd.db,
"get_or_create_db",
AsyncMock(return_value=(None, session_maker)),
)
monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock())
monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository)
monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock())
monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository)
monkeypatch.setattr(
"basic_memory.repository.search_repository.create_search_repository",
lambda *args, **kwargs: object(),
+5 -5
View File
@@ -368,7 +368,7 @@ def test_status_wait_json_timeout_emits_error(
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -387,7 +387,7 @@ def test_schema_validate_json(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value={"error": "No schema found for type 'person'"},
)
@@ -404,7 +404,7 @@ def test_schema_validate_json_error(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_validate",
"basic_memory.cli.commands.schema.mcp_schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -427,7 +427,7 @@ def test_schema_validate_json_strict_exit(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_infer",
"basic_memory.cli.commands.schema.mcp_schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -451,7 +451,7 @@ def test_schema_infer_json(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.mcp.tools.schema_diff",
"basic_memory.cli.commands.schema.mcp_schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_WITH_DRIFT,
)
-95
View File
@@ -1,95 +0,0 @@
"""Tests for `bm man install` (#952 / #610: make `man bm` work)."""
import subprocess
from typer.testing import CliRunner
from basic_memory.cli.app import app
# Importing the module registers the man command group on the top-level app.
import basic_memory.cli.commands.man as man_command # noqa: F401
runner = CliRunner()
def _flattened(output: str) -> str:
# rich wraps console output at the terminal width, which differs between
# local shells and CI — collapse all whitespace so phrase assertions can't
# be split by a line break.
return " ".join(output.split())
def test_man_install_writes_pages_to_target(tmp_path):
"""Install copies every bundled page into <root>/man1 as valid groff."""
result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)])
assert result.exit_code == 0, result.output
bm_page = tmp_path / "man1" / "bm.1"
alias_page = tmp_path / "man1" / "basic-memory.1"
assert bm_page.exists()
assert alias_page.exists()
# groff sanity: a real title header and the alias .so include
assert bm_page.read_text().startswith(".TH BM 1")
assert alias_page.read_text().strip() == ".so man1/bm.1"
assert "Try:" in _flattened(result.output)
def test_man_install_warns_when_root_not_on_manpath(tmp_path, monkeypatch):
"""A root provably absent from manpath output gets the MANPATH hint."""
def fake_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=["manpath"], returncode=0, stdout="/usr/share/man:/opt/man", stderr=""
)
monkeypatch.setattr(man_command.subprocess, "run", fake_run)
result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert "not on your manpath" in _flattened(result.output)
assert "MANPATH" in _flattened(result.output)
def test_man_install_stays_quiet_when_manpath_unavailable(tmp_path, monkeypatch):
"""No manpath binary → no false-alarm warning, install still succeeds."""
def fake_run(*args, **kwargs):
raise FileNotFoundError("manpath")
monkeypatch.setattr(man_command.subprocess, "run", fake_run)
result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert "not on your manpath" not in _flattened(result.output)
def test_man_install_treats_manpath_failure_as_unknown(tmp_path, monkeypatch):
"""manpath exiting non-zero → unknown, no warning."""
def fake_run(*args, **kwargs):
return subprocess.CompletedProcess(args=["manpath"], returncode=1, stdout="", stderr="boom")
monkeypatch.setattr(man_command.subprocess, "run", fake_run)
result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert "not on your manpath" not in _flattened(result.output)
def test_man_install_skips_app_initialization(tmp_path, monkeypatch):
"""man install must not touch the database (PR #971 review).
Installing offline docs only copies packaged files; a locked or broken
local database must not block it, so `man` is in skip_init_commands.
"""
import basic_memory.services.initialization as init_module
def explode(*args, **kwargs):
raise AssertionError("ensure_initialization must not run for `bm man`")
monkeypatch.setattr(init_module, "ensure_initialization", explode)
result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert (tmp_path / "man1" / "bm.1").exists()
-55
View File
@@ -1,55 +0,0 @@
"""Tests for the `bm status --wait` timeout guidance (#959)."""
import pytest
import basic_memory.cli.commands.status as status_module
from basic_memory.cli.commands.status import StatusTimeout, run_status
from basic_memory.schemas import SyncReportResponse
from basic_memory.schemas.project_info import ProjectItem
@pytest.mark.asyncio
async def test_status_wait_timeout_points_at_reindex(monkeypatch, config_manager):
"""The timeout error must hand the user the command that actually indexes.
In CLI-only sessions no sync coordinator runs, so pending changes never
drain and --wait always times out; without the hint the dead end looks
like a hung indexer (#959).
"""
project_item = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="scratch",
path="/tmp/scratch",
is_default=True,
)
pending_report = SyncReportResponse(new={"notes/seed.md"}, total=1)
class FakeProjectClient:
def __init__(self, client):
pass
async def get_status(self, external_id):
return pending_report
class FakeClientContext:
async def __aenter__(self):
return object()
async def __aexit__(self, *args):
return False
async def fake_get_active_project(client, project, context):
return project_item
monkeypatch.setattr(status_module, "get_client", lambda **kwargs: FakeClientContext())
monkeypatch.setattr(status_module, "get_active_project", fake_get_active_project)
monkeypatch.setattr(status_module, "ProjectClient", FakeProjectClient)
with pytest.raises(StatusTimeout) as exc_info:
await run_status(project="scratch", wait=True, timeout=0.01, poll_interval=0.001)
message = str(exc_info.value)
assert "Timed out" in message
assert "bm reindex --project scratch" in message
assert "no Basic Memory server is running" in message
@@ -1,93 +0,0 @@
"""Regression test for issue #940: lost writes on the in-memory SQLite engine.
The in-memory SQLite URL (``sqlite+aiosqlite://``) used to fall back to
SQLAlchemy's StaticPool, which hands the same DBAPI connection to every
concurrently checked-out session. Concurrent asyncio tasks then share one
transaction scope: a rollback issued by one session scoped_session's
exception handling, or the pool's reset-on-return at checkin — silently rolls
back another session's uncommitted writes. During sync this manifested as a
freshly inserted relation row vanishing without any error, which is how
``test_sync_entity_circular_relations`` failed on CI with
``len(entity_b.outgoing_relations) == 0``.
This test pins the isolation invariant directly: a session that rolls back in
one task must never destroy an uncommitted write of a session in another task.
"""
import asyncio
from pathlib import Path
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.models import Base
class _SimulatedIndexingFailure(Exception):
"""Stand-in for any per-file error that _run_bounded swallows during sync."""
@pytest.mark.asyncio
async def test_concurrent_session_rollback_does_not_destroy_uncommitted_writes():
"""A rolled-back session in one task must not erase another task's writes."""
async with db.engine_session_factory(
db_path=Path("unused.db"), db_type=db.DatabaseType.MEMORY
) as (engine, session_maker):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Seed a project and entity so a relation row satisfies its FK constraints.
async with db.scoped_session(session_maker) as session:
await session.execute(
text(
"INSERT INTO project (id, external_id, name, description, path, is_active,"
" is_default, created_at, updated_at, permalink) "
"VALUES (1, 'px', 'p', '', '/tmp', 1, 1, '2024-01-01', '2024-01-01', 'p')"
)
)
await session.execute(
text(
"INSERT INTO entity (id, external_id, title, note_type, content_type,"
" project_id, file_path, created_at, updated_at) "
"VALUES (1, 'ex', 'E', 'note', 'text/markdown', 1, 'e.md',"
" '2024-01-01', '2024-01-01')"
)
)
write_in_flight = asyncio.Event()
async def writer() -> None:
# Mirrors RelationRepository.add_all_ignore_duplicates: INSERT executed,
# commit only happens at scoped_session exit several awaits later.
async with db.scoped_session(session_maker) as session:
await session.execute(
text(
"INSERT INTO relation (project_id, from_id, to_id, to_name,"
" relation_type) VALUES (1, 1, NULL, 'target', 'depends_on')"
)
)
write_in_flight.set()
# Real DB roundtrips (not sleeps) keep this transaction open across
# await points, exactly like the multi-statement sessions in sync.
for _ in range(10):
await session.execute(text("SELECT 1"))
async def failing_reader() -> None:
# Mirrors any per-file failure during batch indexing: scoped_session
# rolls back on the exception path while sibling tasks are mid-write.
await write_in_flight.wait()
with pytest.raises(_SimulatedIndexingFailure):
async with db.scoped_session(session_maker) as session:
await session.execute(text("SELECT 1"))
raise _SimulatedIndexingFailure()
await asyncio.gather(writer(), failing_reader())
async with db.scoped_session(session_maker) as session:
count = (await session.execute(text("SELECT count(*) FROM relation"))).scalar()
assert count == 1, (
"writer's committed INSERT was rolled back by a concurrent session — "
"the in-memory engine is sharing one transaction scope across sessions"
)
+26 -13
View File
@@ -27,6 +27,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_create_entity(self, monkeypatch):
"""Test create_entity calls correct endpoint."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -46,7 +47,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -56,6 +57,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_update_entity(self, monkeypatch):
"""Test update_entity calls correct endpoint without fast query params."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -75,7 +77,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_put", mock_call_put)
monkeypatch.setattr(knowledge_mod, "call_put", mock_call_put)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -85,6 +87,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_patch_entity(self, monkeypatch):
"""Test patch_entity calls correct endpoint without fast query params."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -104,7 +107,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_patch", mock_call_patch)
monkeypatch.setattr(knowledge_mod, "call_patch", mock_call_patch)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -114,6 +117,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_resolve_entity(self, monkeypatch):
"""Test resolve_entity returns external_id."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {"external_id": "entity-uuid-123"}
@@ -122,7 +126,7 @@ class TestKnowledgeClient:
assert "/v2/projects/proj-123/knowledge/resolve" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -132,6 +136,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_sync_file(self, monkeypatch):
"""Test sync_file posts the file path to the sync-file endpoint."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -151,7 +156,7 @@ class TestKnowledgeClient:
assert kwargs.get("json") == {"file_path": "notes/disk-note.md"}
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -161,6 +166,7 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_get_orphans_validates_response(self, monkeypatch):
"""Orphan responses are validated into GraphNode objects."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
from basic_memory.schemas.v2.graph import GraphNode
mock_response = MagicMock()
@@ -180,7 +186,7 @@ class TestKnowledgeClient:
assert "/v2/projects/proj-123/knowledge/orphans" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -205,6 +211,7 @@ class TestSearchClient:
@pytest.mark.asyncio
async def test_search(self, monkeypatch):
"""Test search calls correct endpoint."""
from basic_memory.mcp.clients import search as search_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -218,7 +225,7 @@ class TestSearchClient:
assert kwargs.get("params") == {"page": 1, "page_size": 10}
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr(search_mod, "call_post", mock_call_post)
mock_http = MagicMock()
client = SearchClient(mock_http, "proj-123")
@@ -241,6 +248,7 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_build_context(self, monkeypatch):
"""Test build_context calls correct endpoint."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -256,7 +264,7 @@ class TestMemoryClient:
assert "/v2/projects/proj-123/memory/specs/search" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -266,6 +274,7 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_recent(self, monkeypatch):
"""Test recent calls correct endpoint."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -284,7 +293,7 @@ class TestMemoryClient:
assert params.get("depth") == 2
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -295,6 +304,7 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_recent_with_types(self, monkeypatch):
"""Test recent with types filter."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -312,7 +322,7 @@ class TestMemoryClient:
assert params.get("type") == "note,spec"
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -334,6 +344,7 @@ class TestDirectoryClient:
@pytest.mark.asyncio
async def test_list(self, monkeypatch):
"""Test list calls correct endpoint."""
from basic_memory.mcp.clients import directory as directory_mod
mock_response = MagicMock()
mock_response.json.return_value = [{"name": "folder", "type": "directory"}]
@@ -342,7 +353,7 @@ class TestDirectoryClient:
assert "/v2/projects/proj-123/directory/list" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(directory_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = DirectoryClient(mock_http, "proj-123")
@@ -365,6 +376,7 @@ class TestResourceClient:
@pytest.mark.asyncio
async def test_read(self, monkeypatch):
"""Test read calls correct endpoint."""
from basic_memory.mcp.clients import resource as resource_mod
mock_response = MagicMock()
mock_response.text = "# Note content"
@@ -373,7 +385,7 @@ class TestResourceClient:
assert "/v2/projects/proj-123/resource/entity-123" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(resource_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = ResourceClient(mock_http, "proj-123")
@@ -393,6 +405,7 @@ class TestProjectClient:
@pytest.mark.asyncio
async def test_list_projects(self, monkeypatch):
"""Test list_projects calls correct endpoint."""
from basic_memory.mcp.clients import project as project_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -412,7 +425,7 @@ class TestProjectClient:
assert "/v2/projects" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr(project_mod, "call_get", mock_call_get)
mock_http = MagicMock()
client = ProjectClient(mock_http)
-39
View File
@@ -618,42 +618,3 @@ async def test_get_cloud_control_plane_client_raises_without_credentials(config_
with pytest.raises(RuntimeError, match="Cloud routing requested but no credentials found"):
async with get_cloud_control_plane_client():
pass
@pytest.mark.asyncio
async def test_get_client_workspace_selector_routes_to_cloud(config_manager):
"""A bare workspace selector routes to the cloud proxy, not local ASGI (#954).
This is the create_memory_project(workspace=...) path on a local MCP
server: no factory, no explicit flags, no project_name the selector
alone must imply cloud routing.
"""
cfg = config_manager.load_config()
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = "bmc_test_key_123"
config_manager.save_config(cfg)
async with get_client(workspace="tenant-123") as client:
assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy"
assert client.headers.get("X-Workspace-ID") == "tenant-123"
@pytest.mark.asyncio
async def test_get_client_workspace_selector_without_credentials_fails_fast(
config_manager, monkeypatch
):
"""No credentials + workspace selector must raise, never fall back to local (#954).
The silent local fallback was the bug: a cloud project create either
failed on a cloud-style path or silently created a local project.
"""
cfg = config_manager.load_config()
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = None
cfg.cloud_client_id = "cid"
cfg.cloud_domain = "https://auth.example.test"
config_manager.save_config(cfg)
with pytest.raises(RuntimeError, match="cloud workspace was requested"):
async with get_client(workspace="team-slug"):
pass
+7 -7
View File
@@ -63,7 +63,7 @@ class TestSchemaClientValidate:
assert kwargs.get("params") == {}
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
result = await schema_client.validate()
assert isinstance(result, ValidationReport)
@@ -88,7 +88,7 @@ class TestSchemaClientValidate:
assert kwargs["params"]["note_type"] == "person"
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
result = await schema_client.validate(note_type="person")
assert result.note_type == "person"
@@ -113,7 +113,7 @@ class TestSchemaClientValidate:
assert kwargs["params"]["identifier"] == "people/alice"
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
result = await schema_client.validate(identifier="people/alice")
assert result.total_notes == 1
@@ -144,7 +144,7 @@ class TestSchemaClientInfer:
assert kwargs["params"]["threshold"] == 0.25
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
result = await schema_client.infer("person")
assert isinstance(result, InferenceReport)
@@ -171,7 +171,7 @@ class TestSchemaClientInfer:
assert kwargs["params"]["threshold"] == 0.5
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
result = await schema_client.infer("meeting", threshold=0.5)
assert result.note_type == "meeting"
@@ -197,7 +197,7 @@ class TestSchemaClientDiff:
assert url == "/v2/projects/test-project-id/schema/diff/person"
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get)
result = await schema_client.diff("person")
assert isinstance(result, DriftReport)
@@ -235,7 +235,7 @@ class TestSchemaClientDiff:
async def mock_call_get(client, url, **kwargs):
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get)
result = await schema_client.diff("person")
assert len(result.new_fields) == 1
+11 -314
View File
@@ -679,7 +679,7 @@ async def test_resolve_workspace_project_identifier_handles_qualified_and_collis
)
index = _build_workspace_project_index((personal, acme), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -694,170 +694,6 @@ async def test_resolve_workspace_project_identifier_handles_qualified_and_collis
assert resolved.project.external_id == "personal-project-id"
def _patch_index(monkeypatch, workspaces, entries):
"""Install a fake workspace/project index for resolver tests."""
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import _build_workspace_project_index
index = _build_workspace_project_index(workspaces, entries)
async def fake_index(context=None, force_refresh=False):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@pytest.mark.asyncio
async def test_resolve_workspace_identifier_by_slug_tenant_id_and_name(monkeypatch):
"""Qualified routes resolve the workspace segment by slug, tenant_id, or display name."""
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
resolve_workspace_project_identifier,
)
acme = _workspace(
tenant_id="acme-tenant-uuid",
workspace_type="organization",
slug="acme-slug",
name="Acme Corp",
role="editor",
)
entries = (
WorkspaceProjectEntry(
workspace=acme,
project=_project("Meeting Notes", id=1, external_id="acme-project-id"),
),
)
_patch_index(monkeypatch, (acme,), entries)
# slug (existing behavior, stays green)
by_slug = await resolve_workspace_project_identifier("acme-slug/meeting-notes")
assert by_slug.project.external_id == "acme-project-id"
# tenant_id (exact, opaque id)
by_tenant = await resolve_workspace_project_identifier("acme-tenant-uuid/meeting-notes")
assert by_tenant.project.external_id == "acme-project-id"
# display name, case-insensitive
by_name = await resolve_workspace_project_identifier("ACME corp/meeting-notes")
assert by_name.project.external_id == "acme-project-id"
@pytest.mark.asyncio
async def test_resolve_workspace_identifier_ambiguous_name_lists_candidates(monkeypatch):
"""A display name shared by multiple workspaces fails fast naming the candidate slugs."""
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
resolve_workspace_project_identifier,
)
alpha = _workspace(
tenant_id="alpha-tenant",
workspace_type="organization",
slug="research-alpha",
name="Research",
role="editor",
)
beta = _workspace(
tenant_id="beta-tenant",
workspace_type="organization",
slug="research-beta",
name="Research",
role="editor",
)
entries = (
WorkspaceProjectEntry(
workspace=alpha,
project=_project("Notes", id=1, external_id="alpha-project-id"),
),
WorkspaceProjectEntry(
workspace=beta,
project=_project("Notes", id=2, external_id="beta-project-id"),
),
)
_patch_index(monkeypatch, (alpha, beta), entries)
with pytest.raises(ValueError) as exc_info:
await resolve_workspace_project_identifier("Research/notes")
message = str(exc_info.value)
assert "matched multiple workspaces" in message
assert "research-alpha" in message
assert "research-beta" in message
assert "slug or tenant_id" in message
@pytest.mark.asyncio
async def test_resolve_workspace_identifier_unknown_lists_tried_forms(monkeypatch):
"""An unknown workspace identifier reports the slug/tenant_id/name forms that were tried."""
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
resolve_workspace_project_identifier,
)
acme = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
entries = (
WorkspaceProjectEntry(
workspace=acme,
project=_project("Notes", id=1, external_id="acme-project-id"),
),
)
_patch_index(monkeypatch, (acme,), entries)
with pytest.raises(ValueError) as exc_info:
await resolve_workspace_project_identifier("nonexistent/notes")
message = str(exc_info.value)
assert "was not found by slug, tenant_id, or name" in message
assert "acme" in message
@pytest.mark.asyncio
async def test_resolve_workspace_identifier_slug_wins_over_name_collision(monkeypatch):
"""A name that equals another workspace's slug resolves to the slug owner."""
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
resolve_workspace_project_identifier,
)
# slug_owner.slug == "shared"; name_owner.name == "shared" — the slug owner must win.
slug_owner = _workspace(
tenant_id="slug-owner-tenant",
workspace_type="organization",
slug="shared",
name="Slug Owner",
role="editor",
)
name_owner = _workspace(
tenant_id="name-owner-tenant",
workspace_type="organization",
slug="name-owner-slug",
name="shared",
role="editor",
)
entries = (
WorkspaceProjectEntry(
workspace=slug_owner,
project=_project("Notes", id=1, external_id="slug-owner-project-id"),
),
WorkspaceProjectEntry(
workspace=name_owner,
project=_project("Notes", id=2, external_id="name-owner-project-id"),
),
)
_patch_index(monkeypatch, (slug_owner, name_owner), entries)
resolved = await resolve_workspace_project_identifier("shared/notes")
assert resolved.workspace.slug == "shared"
assert resolved.project.external_id == "slug-owner-project-id"
@pytest.mark.asyncio
async def test_detect_project_from_memory_url_prefix_resolves_workspace_slug(monkeypatch):
import basic_memory.mcp.project_context as project_context
@@ -895,7 +731,7 @@ async def test_detect_project_from_memory_url_prefix_resolves_workspace_slug(mon
)
index = _build_workspace_project_index((personal, team), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1026,7 +862,7 @@ async def test_detect_project_from_identifier_prefix_resolves_workspace_with_loc
),
)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1081,7 +917,7 @@ async def test_resolve_workspace_qualified_memory_url_ignores_workspace_project_
)
index = _build_workspace_project_index((workspace,), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1121,7 +957,7 @@ async def test_resolve_workspace_qualified_memory_url_fails_on_duplicate_project
)
index = _build_workspace_project_index((team,), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1162,7 +998,7 @@ async def test_resolve_workspace_qualified_memory_url_uses_personal_canonical_pa
)
index = _build_workspace_project_index((personal,), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1204,7 +1040,7 @@ async def test_resolve_workspace_qualified_memory_url_keeps_org_canonical_path_w
)
index = _build_workspace_project_index((team,), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1318,7 +1154,7 @@ async def test_resolve_workspace_project_identifier_uses_active_workspace_for_du
)
index = _build_workspace_project_index((personal, acme), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1374,7 +1210,7 @@ async def test_resolve_workspace_project_identifier_resolves_by_external_id(monk
)
index = _build_workspace_project_index((personal, acme), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1411,7 +1247,7 @@ async def test_resolve_workspace_project_identifier_normalizes_uuid_forms(monkey
)
index = _build_workspace_project_index((workspace,), entries)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
@@ -1666,7 +1502,7 @@ async def test_get_project_client_with_cloud_project_id_routes_to_workspace_with
(WorkspaceProjectEntry(workspace=personal, project=cloud_project),),
)
async def fake_index(context=None, force_refresh=False):
async def fake_index(context=None):
return index
get_client_calls: list[dict[str, str | None]] = []
@@ -3153,142 +2989,3 @@ class TestGetProjectClientRoutingOrder:
finally:
# Restore original factory to avoid polluting other tests
async_client._client_factory = original_factory
@pytest.mark.asyncio
async def test_resolver_refreshes_index_on_miss(monkeypatch):
"""A stale index miss triggers one rebuild and the retry resolves (#956).
Mirrors the field failure: a teammate (or the CLI) creates a project after
the session index was built; project_id routing must find it without a
session restart.
"""
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
WorkspaceProjectLookupMiss,
_build_workspace_project_index,
resolve_workspace_project_identifier,
)
team = _workspace(
tenant_id="team-tenant",
workspace_type="organization",
slug="team",
name="Team",
role="owner",
is_default=True,
)
old_entry = WorkspaceProjectEntry(
workspace=team,
project=_project("Existing", id=1, external_id="11111111-1111-4111-8111-111111111111"),
)
new_entry = WorkspaceProjectEntry(
workspace=team,
project=_project("Manual", id=2, external_id="22222222-2222-4222-8222-222222222222"),
)
stale = _build_workspace_project_index((team,), (old_entry,))
fresh = _build_workspace_project_index((team,), (old_entry, new_entry))
calls = []
async def fake_index(context=None, force_refresh=False):
calls.append(force_refresh)
return fresh if force_refresh else stale
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
# By external_id (the project_id routing path that failed in the field)
resolved = await resolve_workspace_project_identifier(
"22222222-2222-4222-8222-222222222222"
)
assert resolved.project.permalink == "manual"
assert calls == [False, True]
# By name, same refresh-and-retry
calls.clear()
resolved = await resolve_workspace_project_identifier("manual")
assert resolved.project.permalink == "manual"
assert calls == [False, True]
# A project absent from the fresh index too: exactly one refresh, then the
# authoritative miss propagates
calls.clear()
with pytest.raises(WorkspaceProjectLookupMiss):
await resolve_workspace_project_identifier("never-existed")
assert calls == [False, True]
# A hit on the cached index never triggers a rebuild
calls.clear()
resolved = await resolve_workspace_project_identifier("existing")
assert resolved.project.permalink == "existing"
assert calls == [False]
@pytest.mark.asyncio
async def test_resolve_project_and_path_keeps_patterns_project_qualified(
config_manager,
monkeypatch,
):
"""Glob patterns are qualified with the project prefix only, never the workspace (#957).
The search index stores project-qualified permalinks (manual/man3/...), so a
workspace-qualified pattern (<ws>/manual/man3*) can never match anything.
Direct URLs keep workspace qualification (the link resolver handles them);
patterns have no resolver fallback and must match the index form.
"""
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import resolve_project_and_path
from basic_memory.schemas.project_info import ProjectItem
config = config_manager.load_config()
config.permalinks_include_project = True
config_manager.save_config(config)
context = ContextState()
cached_project = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="manual",
path="/tmp/manual",
is_default=False,
)
team_workspace = _workspace(
tenant_id="team-tenant",
workspace_type="organization",
slug="team-paul",
name="Team Paul",
role="editor",
)
await context.set_state("active_project", cached_project.model_dump())
await context.set_state("active_workspace", team_workspace.model_dump())
async def fake_call_post(*args, **kwargs):
raise ToolError("project not found")
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
# Bare pattern: project prefix only — no workspace slug
_, resolved_path, _ = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://man3/*",
context=ctx(context),
)
assert resolved_path == "manual/man3/*"
# Workspace-qualified pattern URL: workspace stripped down to index form
_, resolved_path, _ = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://team-paul/manual/man3/*",
context=ctx(context),
)
assert resolved_path == "manual/man3/*"
# Direct URLs keep the full workspace-qualified canonical path
_, resolved_path, _ = await resolve_project_and_path(
client=cast(Any, None),
identifier="memory://man3/write-note-3",
context=ctx(context),
)
assert resolved_path == "team-paul/manual/man3/write-note-3"
@@ -0,0 +1,247 @@
"""Tests for the basic_memory_diagnostics MCP tool."""
import json
import platform
import sys
from unittest.mock import MagicMock, patch
import basic_memory
from basic_memory.mcp.tools.basic_memory_diagnostics import (
_redact_config,
_redact_url,
basic_memory_diagnostics,
)
# ---------------------------------------------------------------------------
# Unit tests for _redact_config helper
# ---------------------------------------------------------------------------
def test_redact_config_removes_cloud_api_key():
raw = {"cloud_api_key": "bmc_secret", "default_project": "main", "projects": {}}
result = _redact_config(raw)
assert "cloud_api_key" not in result
assert result["default_project"] == "main"
assert "projects" in result
def test_redact_config_passes_through_safe_fields():
raw = {"default_project": "main", "log_level": "INFO", "env": "dev"}
result = _redact_config(raw)
assert result == raw
def test_redact_config_empty_dict():
assert _redact_config({}) == {}
# ---------------------------------------------------------------------------
# Tests for the basic_memory_diagnostics tool
# ---------------------------------------------------------------------------
def test_diagnostics_returns_string():
result = basic_memory_diagnostics()
assert isinstance(result, str)
def test_diagnostics_includes_version():
result = basic_memory_diagnostics()
assert basic_memory.__version__ in result
def test_diagnostics_includes_python_version():
result = basic_memory_diagnostics()
# sys.version can be multi-line; just check the version tuple prefix
major_minor = f"{sys.version_info.major}.{sys.version_info.minor}"
assert major_minor in result
def test_diagnostics_includes_platform():
result = basic_memory_diagnostics()
assert platform.machine() in result
def test_diagnostics_includes_config_path(tmp_path):
"""Config path section should appear in output."""
with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr:
mock_mgr = MagicMock()
mock_mgr.config_dir = tmp_path
MockMgr.return_value = mock_mgr
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"default_project": "main", "projects": {}}))
result = basic_memory_diagnostics()
assert str(tmp_path) in result
assert "Config path:" in result
def test_diagnostics_config_exists_with_valid_json(tmp_path):
"""When config file exists, its safe contents should appear as JSON."""
config_data = {
"default_project": "research",
"projects": {"research": {"path": str(tmp_path / "research")}},
}
with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr:
mock_mgr = MagicMock()
mock_mgr.config_dir = tmp_path
MockMgr.return_value = mock_mgr
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
result = basic_memory_diagnostics()
assert "research" in result
assert "```json" in result
def test_diagnostics_redacts_cloud_api_key(tmp_path):
"""cloud_api_key must never appear in diagnostic output."""
config_data = {
"default_project": "main",
"cloud_api_key": "bmc_super_secret_token",
"projects": {},
}
with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr:
mock_mgr = MagicMock()
mock_mgr.config_dir = tmp_path
MockMgr.return_value = mock_mgr
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
result = basic_memory_diagnostics()
assert "bmc_super_secret_token" not in result
assert "cloud_api_key" not in result
def test_diagnostics_config_missing(tmp_path):
"""When config file does not exist, output should say so."""
with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr:
mock_mgr = MagicMock()
mock_mgr.config_dir = tmp_path
MockMgr.return_value = mock_mgr
# Ensure no config.json is present
config_file = tmp_path / "config.json"
assert not config_file.exists()
result = basic_memory_diagnostics()
assert "Config exists: False" in result
assert "<config file not found>" in result
def test_diagnostics_output_sections():
"""All expected section headers should be present."""
result = basic_memory_diagnostics()
assert "# Basic Memory Diagnostics" in result
assert "## Version" in result
assert "## System" in result
assert "## Configuration" in result
# ---------------------------------------------------------------------------
# Unit tests for _redact_url helper
# ---------------------------------------------------------------------------
def test_redact_url_strips_password():
url = "postgresql://user:secret@localhost/mydb"
result = _redact_url(url)
assert "secret" not in result
assert "user" not in result
assert "localhost" in result
assert "mydb" in result
assert "***" in result
def test_redact_url_strips_only_password_when_no_username():
# password-only userinfo (unusual but valid per RFC)
url = "postgresql://:secret@db.example.com/app"
assert _redact_url(url) == "postgresql://***@db.example.com/app"
def test_redact_url_preserves_port():
url = "postgresql://admin:pw@db.internal:5432/prod"
assert _redact_url(url) == "postgresql://***@db.internal:5432/prod"
def test_redact_url_no_credentials_unchanged():
url = "postgresql://db.internal:5432/prod"
assert _redact_url(url) == url
def test_redact_url_non_url_string_unchanged():
# Bare file paths / non-URL values must not be mangled.
path = "/home/user/.local/share/basic-memory/main.db"
assert _redact_url(path) == path
# ---------------------------------------------------------------------------
# _redact_config tests for database_url
# ---------------------------------------------------------------------------
def test_redact_config_scrubs_database_url_credentials():
raw = {
"default_project": "main",
"database_url": "postgresql://dbuser:dbpass@host.example.com:5432/bm",
"projects": {},
}
result = _redact_config(raw)
# Exact match: credentials replaced, host/port/db preserved for diagnostics.
assert result["database_url"] == "postgresql://***@host.example.com:5432/bm"
def test_redact_config_leaves_database_url_without_credentials():
raw = {"database_url": "sqlite:////tmp/basic-memory/main.db"}
result = _redact_config(raw)
assert result["database_url"] == "sqlite:////tmp/basic-memory/main.db"
def test_redact_config_drops_secret_fields_independently():
raw = {
"cloud_api_key": "bmc_top_secret",
"database_url": "postgresql://dbuser:dbpassword@host/db",
"default_project": "main",
}
result = _redact_config(raw)
assert "cloud_api_key" not in result
assert "dbpassword" not in result["database_url"]
assert "dbuser" not in result["database_url"]
assert "main" == result["default_project"]
# ---------------------------------------------------------------------------
# Integration: database_url redaction surfaces in diagnostic output
# ---------------------------------------------------------------------------
def test_diagnostics_redacts_database_url_password(tmp_path):
"""Postgres password in database_url must not appear in diagnostic output."""
config_data = {
"default_project": "main",
"database_url": "postgresql://pguser:supersecret@db.internal:5432/basicmemory",
"projects": {},
}
with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr:
mock_mgr = MagicMock()
mock_mgr.config_dir = tmp_path
MockMgr.return_value = mock_mgr
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
result = basic_memory_diagnostics()
assert "supersecret" not in result
assert "pguser" not in result
# Host and port remain visible for diagnostics.
assert "db.internal" in result
assert "5432" in result
-50
View File
@@ -254,53 +254,3 @@ async def test_build_context_markdown_not_found(client, test_project):
assert isinstance(result, str)
assert "No results found" in result
assert test_project.name in result
def test_format_entity_block_renders_unresolved_relations_by_name():
"""Unresolved forward references render their target text, not [[None]] (#955)."""
from datetime import UTC, datetime
from basic_memory.mcp.tools.build_context import _format_entity_block
from basic_memory.schemas.memory import (
ContextResult,
EntitySummary,
RelationSummary,
)
now = datetime.now(UTC)
page = EntitySummary(
external_id="entity-1",
entity_id=1,
title="write-note(3)",
permalink="man3/write-note-3",
content="# write-note(3)",
file_path="man3/write-note-3.md",
created_at=now,
)
unresolved = RelationSummary(
title="see_also: edit-note(3)",
file_path="man3/write-note-3.md",
permalink="man3/write-note-3/see-also/edit-note-3",
relation_type="see_also",
from_entity="write-note(3)",
to_entity=None,
to_name="edit-note(3)",
created_at=now,
)
resolved = RelationSummary(
title="see_also: bm-note(5)",
file_path="man3/write-note-3.md",
permalink="man3/write-note-3/see-also/bm-note-5",
relation_type="see_also",
from_entity="write-note(3)",
to_entity="bm-note(5)",
to_name="bm-note(5)",
created_at=now,
)
block = _format_entity_block(
ContextResult(primary_result=page, observations=[], related_results=[unresolved, resolved])
)
assert "- see_also [[edit-note(3)]]" in block
assert "- see_also [[bm-note(5)]]" in block
assert "[[None]]" not in block
+2 -7
View File
@@ -1,6 +1,5 @@
"""Tests for cloud discovery MCP tools."""
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
from basic_memory.mcp.tools import cloud_info, release_notes
@@ -8,10 +7,8 @@ def test_cloud_info_tool_returns_expected_copy():
result = cloud_info()
assert "# Basic Memory Cloud (optional)" in result
assert OSS_DISCOUNT_CODE in result
assert "{{OSS_DISCOUNT_CODE}}" in result
assert "bm cloud login" in result
# Regression (#958): the template placeholder must never reach users.
assert "{{" not in result
def test_release_notes_tool_returns_expected_copy():
@@ -19,7 +16,5 @@ def test_release_notes_tool_returns_expected_copy():
assert "# Release Notes" in result
assert "2026-02-06" in result
assert OSS_DISCOUNT_CODE in result
assert "{{OSS_DISCOUNT_CODE}}" in result
assert "bm cloud login" in result
# Regression (#958): the template placeholder must never reach users.
assert "{{" not in result
-17
View File
@@ -6,10 +6,7 @@ import inspect
from collections.abc import Callable
from typing import Any, cast
import pytest
from basic_memory.mcp import tools
from basic_memory.mcp.server import mcp
EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
@@ -109,7 +106,6 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"content",
"directory",
"project",
"workspace",
"project_id",
"tags",
"note_type",
@@ -161,16 +157,3 @@ def test_mcp_tool_signatures_are_stable():
for tool_name, tool_obj in TOOL_FUNCTIONS.items():
assert _signature_params(tool_obj) == EXPECTED_TOOL_SIGNATURES[tool_name]
@pytest.mark.asyncio
async def test_mcp_tools_have_title_and_tags():
"""Every registered MCP tool must declare a human-readable title and at least one tag.
This guards against regressions where a new tool is added without the Phase 1
FastMCP metadata (title + tags) required by issue #826.
"""
tool_list = await mcp.list_tools()
for tool in tool_list:
assert tool.title, f"Tool '{tool.name}' is missing a 'title' annotation"
assert tool.tags, f"Tool '{tool.name}' is missing 'tags' annotation"
-158
View File
@@ -2115,161 +2115,3 @@ def test_default_search_type_falls_back_to_text_when_semantic_disabled():
with patch.object(search_module, "get_container", return_value=mock_container):
assert search_module._default_search_type() == "text"
# --- Tests for note_types/entity_types/categories comma-split fix (#930, Codex review) ---
def test_search_notes_note_types_annotation_splits_comma_strings():
"""The note_types parameter annotation must parse every documented input form (#930).
Direct function calls bypass the BeforeValidator; validate through the same
Annotated metadata pydantic applies on the MCP path. The old coerce_list wrapped a
bare comma string as the single literal type ["note,task"]; parse_str_list splits it.
"""
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["note", "task"])
comma_string = adapter.validate_python("note,task")
json_string = adapter.validate_python('["note", "task"]')
single_string = adapter.validate_python("note")
comma_in_list = adapter.validate_python(["note,task"])
assert real_list == ["note", "task"]
assert comma_string == real_list, "comma string must behave like the real list"
assert json_string == real_list
assert single_string == ["note"]
assert comma_in_list == real_list, "list with comma element must be flattened"
def test_search_notes_entity_types_annotation_splits_comma_strings():
"""The entity_types parameter annotation must parse every documented input form (#930)."""
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["entity", "observation"])
comma_string = adapter.validate_python("entity,observation")
comma_in_list = adapter.validate_python(["entity,observation"])
assert real_list == ["entity", "observation"]
assert comma_string == real_list
assert comma_in_list == real_list
def test_search_notes_categories_annotation_splits_comma_strings():
"""The categories parameter annotation must parse every documented input form (#930)."""
annotation = inspect.signature(search_notes).parameters["categories"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["requirement", "decision"])
comma_string = adapter.validate_python("requirement,decision")
comma_in_list = adapter.validate_python(["requirement,decision"])
assert real_list == ["requirement", "decision"]
assert comma_string == real_list
assert comma_in_list == real_list
def test_search_notes_note_types_annotation_rejects_non_string_list_elements():
"""note_types=[42] must fail Pydantic validation, not be stringified to ['42'].
parse_str_list used str(raw) to coerce list elements, silently accepting [42] as
["42"]. The fix guards against non-string list elements and returns the original
value so Pydantic rejects it with a clear error.
"""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["note", 42])
# All-string lists remain valid.
assert adapter.validate_python(["note", "task"]) == ["note", "task"]
def test_search_notes_entity_types_annotation_rejects_non_string_list_elements():
"""entity_types=[42] must fail Pydantic validation, not be stringified."""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["entity", 42])
assert adapter.validate_python(["entity", "observation"]) == ["entity", "observation"]
def test_search_notes_categories_annotation_rejects_non_string_list_elements():
"""categories=[42] must fail Pydantic validation, not be stringified."""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["categories"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["requirement", 42])
assert adapter.validate_python(["requirement", "decision"]) == ["requirement", "decision"]
@pytest.mark.asyncio
async def test_search_notes_direct_call_splits_comma_note_types(client, test_project):
"""Direct callers bypass the BeforeValidator, so the body must normalize note_types.
Regression for the CLI path: `bm tool search-notes --type note,task` calls this
function directly with Typer's collected list ["note,task"], which must split into
["note", "task"] and match the note correctly (#930, Codex review follow-up).
"""
await write_note(
project=test_project.name,
title="Direct NoteType Split Note",
directory="test",
content="# Direct NoteType Split Note\nNoteTypeSplitToken body",
)
async def found(note_types_value: list[str] | None) -> bool:
result = await search_notes(
project=test_project.name,
query="NoteTypeSplitToken",
search_type="text",
output_format="json",
note_types=note_types_value,
)
assert isinstance(result, dict), f"search failed: {result}"
return any(r["title"] == "Direct NoteType Split Note" for r in result["results"])
assert await found(None), "no filter must match (sanity)"
assert await found(["note"]), "plain single-type list must match (sanity)"
# The CLI regression: Typer collects --type note,task as the single element "note,task".
assert await found(["note,task"]), "comma list element must be flattened and match 'note'"
# Negative control: a specific nonexistent type must not match.
assert not await found(["nonexistent_type"])
def test_search_notes_parse_str_list_rejects_non_string_list_elements_in_place():
"""parse_str_list must return non-str list elements unchanged for Pydantic rejection.
The old implementation used str(raw) which silently coerced [42] -> ['42'],
causing bad caller data to become silent no-result searches instead of a
clear Pydantic validation error.
"""
from basic_memory.utils import parse_str_list
# Non-string list elements pass through unchanged.
assert parse_str_list([42]) == [42] # type: ignore[arg-type]
assert parse_str_list(["ok", 42]) == ["ok", 42] # type: ignore[arg-type]
assert parse_str_list([{"a": 1}]) == [{"a": 1}] # type: ignore[arg-type]
# All-string lists still work correctly.
assert parse_str_list(["note", "task"]) == ["note", "task"]
assert parse_str_list(["note,task"]) == ["note", "task"]
-127
View File
@@ -7,104 +7,10 @@ import pytest
from basic_memory import config as config_module
from basic_memory.mcp.tools import write_note, read_note, delete_note
from basic_memory.mcp.tools.write_note import _compose_workspace_project_route
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.utils import normalize_newlines
# ---------------------------------------------------------------------------
# _compose_workspace_project_route unit tests
# ---------------------------------------------------------------------------
def test_write_note_workspace_project_route_passthrough_without_workspace():
"""Without workspace, the project string passes through unchanged."""
assert _compose_workspace_project_route(
workspace=None,
project="my-project",
project_id=None,
) == "my-project"
def test_write_note_workspace_project_route_combines_workspace_and_project():
"""workspace + project are joined as 'workspace/project'."""
assert _compose_workspace_project_route(
workspace="acme",
project="docs",
project_id=None,
) == "acme/docs"
def test_write_note_workspace_project_route_passes_qualified_project_unchanged():
"""A pre-qualified 'workspace/project' string passes through when workspace is None."""
assert _compose_workspace_project_route(
workspace=None,
project="acme/docs",
project_id=None,
) == "acme/docs"
@pytest.mark.parametrize(
("route_kwargs", "message"),
[
(
{"workspace": " ", "project": "docs", "project_id": None},
"workspace must not be empty",
),
(
{"workspace": "acme/extra", "project": "docs", "project_id": None},
"workspace must be a single workspace",
),
(
{"workspace": "acme", "project": "docs", "project_id": "some-uuid"},
"workspace cannot be combined with project_id",
),
(
{"workspace": "acme", "project": None, "project_id": None},
"workspace requires an explicit project",
),
(
{"workspace": "acme", "project": "workspace/project", "project_id": None},
"not both",
),
],
)
def test_write_note_workspace_project_route_rejects_invalid_inputs(route_kwargs, message):
"""Ambiguous workspace/project argument combinations should raise ValueError."""
with pytest.raises(ValueError, match=message):
_compose_workspace_project_route(**route_kwargs)
@pytest.mark.asyncio
async def test_write_note_accepts_workspace_param(app, test_project):
"""write_note routes correctly when workspace= is passed alongside project=."""
# The test_project fixture gives us a project with a known name. Passing
# workspace="" (blank) is invalid, so we test that the combined route is
# built and that a valid workspace+project pair creates the note.
result = await write_note(
title="Workspace Routing Test",
directory="ws-test",
content="# Workspace Routing Test\n\nRouted via workspace param.",
# project alone (no workspace) — confirms the parameter is accepted
project=test_project.name,
)
assert "# Created note" in result
assert f"project: {test_project.name}" in result
@pytest.mark.asyncio
async def test_write_note_workspace_invalid_raises_before_routing(app, test_project):
"""Passing an empty workspace= should raise ValueError, not silently misbehave."""
with pytest.raises(ValueError, match="workspace must not be empty"):
await write_note(
title="Should Fail",
directory="ws-test",
content="# Should Fail",
workspace="", # empty — must be rejected
project=test_project.name,
)
@pytest.mark.asyncio
async def test_write_note(app, test_project):
"""Test creating a new note.
@@ -570,39 +476,6 @@ async def test_write_note_preserves_content_frontmatter(app, test_project):
)
@pytest.mark.asyncio
async def test_write_note_single_line_inline_fence_is_body_issue_972(app, test_project):
"""Single-line content starting with `---` must be stored as body, not frontmatter.
Reproduces issue #972: a one-line string where `\\n` are literal backslash-n
characters (a common CLI/agent input shape) was misread as frontmatter, merging a
garbage `\\nstatus` YAML key into the note and silently dropping the inline
`---...---` segment from the body.
"""
one_line = r"---\nstatus: active\n---\nDiscussed Q3 roadmap with Anthony."
await write_note(
project=test_project.name,
title="Meeting Notes",
directory="meetings",
content=one_line,
)
content = await read_note("meetings/meeting-notes", project=test_project.name)
assert isinstance(content, str)
# The literal one-line string survives verbatim in the body...
assert one_line in content
# ...and no garbage `\nstatus` key leaked into the generated YAML frontmatter.
# Inspect only the frontmatter block (between the first pair of fence lines).
lines = content.splitlines()
assert lines[0] == "---"
closing = lines.index("---", 1)
frontmatter_block = lines[1:closing]
assert not any("status" in line for line in frontmatter_block)
@pytest.mark.asyncio
async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
"""Test fix for GitHub Issue #139: UNIQUE constraint failed: entity.permalink.
@@ -1,226 +0,0 @@
"""Service-level integration test for the entity-aware ranking boost (#951).
Drives a fully wired SearchService over a real database with a deterministic stub
embedding provider so vector similarity is controlled. Verifies that when the boost
is enabled, an entity-matching document outranks a higher-similarity non-matching
document, and that ordering is unchanged when the boost is disabled.
No model inference is involved: the stub provider returns fixed unit vectors, so the
test is fast and deterministic on both SQLite and Postgres.
"""
from __future__ import annotations
import math
from typing import Any
import pytest
import pytest_asyncio
from basic_memory.config import BasicMemoryConfig
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.schemas.search import SearchQuery, SearchRetrievalMode
from basic_memory.services.entity_service import EntityService
from basic_memory.services.file_service import FileService
from basic_memory.services.search_service import SearchService
# --- Deterministic stub embedding provider ---
_STUB_DIMENSIONS = 4
def _unit(vector: list[float]) -> list[float]:
norm = math.sqrt(sum(component * component for component in vector)) or 1.0
return [component / norm for component in vector]
class _StubEmbeddingProvider:
"""Maps known text fragments to fixed unit vectors for controlled similarity.
The query is engineered to sit closer (cosine) to the non-matching "hobbies"
document than to the gold "Joanna" document, reproducing the #951 failure where
generic semantic similarity outranks the entity-matching gold doc.
"""
model_name = "stub-entity-boost"
dimensions = _STUB_DIMENSIONS
def _vector_for(self, text: str) -> list[float]:
lowered = text.lower()
if "joanna" in lowered:
# Gold doc: shares some direction with the query but less than the decoy.
return _unit([0.6, 0.8, 0.0, 0.0])
if "hobbies" in lowered or "pastime" in lowered:
# Decoy doc: closest to the query direction.
return _unit([0.95, 0.31, 0.0, 0.0])
return _unit([0.0, 0.0, 1.0, 0.0])
async def embed_query(self, text: str) -> list[float]:
# Query direction is closest to the decoy vector above.
return _unit([0.97, 0.24, 0.0, 0.0])
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [self._vector_for(text) for text in texts]
def runtime_log_attrs(self) -> dict[str, Any]:
return {}
# --- Fixtures ---
async def _build_search_service(
*,
session_maker,
test_project,
base_app_config: BasicMemoryConfig,
file_service: FileService,
entity_repository: EntityRepository,
boost_enabled: bool,
) -> SearchService:
"""Build a SearchService with semantic search + a deterministic stub provider."""
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.config import DatabaseBackend
app_config = base_app_config.model_copy(
update={
"semantic_search_enabled": True,
"semantic_min_similarity": 0.0,
"search_entity_boost_enabled": boost_enabled,
"search_entity_boost_weight": 0.3,
"search_entity_boost_max_terms": 3,
}
)
provider = _StubEmbeddingProvider()
if app_config.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
search_repo: SearchRepository = PostgresSearchRepository(
session_maker,
project_id=test_project.id,
app_config=app_config,
embedding_provider=provider,
)
else:
# Pass the stub provider at construction time so __init__ does not
# instantiate the real configured provider when semantic_search_enabled=True.
repo = SQLiteSearchRepository(
session_maker,
project_id=test_project.id,
app_config=app_config,
embedding_provider=provider,
)
search_repo = repo
service = SearchService(search_repo, entity_repository, file_service)
await service.init_search_index()
return service
@pytest_asyncio.fixture
async def boost_entities(
entity_service: EntityService,
):
"""Index two entities: a decoy 'hobbies' doc and the gold 'Joanna' doc."""
decoy, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Common Hobbies and Pastimes",
note_type="note",
directory="people",
content="A general overview of hobbies and pastimes people enjoy.",
)
)
gold, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Joanna",
note_type="note",
directory="people",
content="Notes about Joanna and what she likes to do.",
)
)
return decoy, gold
# --- Tests ---
async def _sync_vectors(service: SearchService, entity_ids: list[int]) -> None:
"""Embed the indexed entities via the stub provider."""
await service.sync_entity_vectors_batch(entity_ids)
@pytest.mark.asyncio
async def test_entity_boost_enabled_promotes_gold_doc(
session_maker,
test_project,
app_config,
file_service,
entity_repository,
boost_entities,
):
decoy, gold = boost_entities
service = await _build_search_service(
session_maker=session_maker,
test_project=test_project,
base_app_config=app_config,
file_service=file_service,
entity_repository=entity_repository,
boost_enabled=True,
)
# Re-index the entities through this service so vector tables exist for it.
for entity in (decoy, gold):
await service.index_entity(entity)
await _sync_vectors(service, [decoy.id, gold.id])
results = await service.search(
SearchQuery(
text="What are Joanna's hobbies?",
retrieval_mode=SearchRetrievalMode.HYBRID,
),
limit=10,
)
entity_ids = [r.entity_id for r in results]
assert gold.id in entity_ids and decoy.id in entity_ids
# With the boost on, the entity-matching gold doc ranks ahead of the
# higher-similarity decoy.
assert entity_ids.index(gold.id) < entity_ids.index(decoy.id)
@pytest.mark.asyncio
async def test_entity_boost_disabled_keeps_similarity_order(
session_maker,
test_project,
app_config,
file_service,
entity_repository,
boost_entities,
):
decoy, gold = boost_entities
service = await _build_search_service(
session_maker=session_maker,
test_project=test_project,
base_app_config=app_config,
file_service=file_service,
entity_repository=entity_repository,
boost_enabled=False,
)
for entity in (decoy, gold):
await service.index_entity(entity)
await _sync_vectors(service, [decoy.id, gold.id])
results = await service.search(
SearchQuery(
text="What are Joanna's hobbies?",
retrieval_mode=SearchRetrievalMode.HYBRID,
),
limit=10,
)
entity_ids = [r.entity_id for r in results]
assert gold.id in entity_ids and decoy.id in entity_ids
# With the boost off, the higher-similarity decoy ranks ahead of the gold doc.
assert entity_ids.index(decoy.id) < entity_ids.index(gold.id)
+3 -5
View File
@@ -291,11 +291,9 @@ async def test_delete_entity_with_relations(entity_repository: EntityRepository,
remaining_relations = result.scalars().all()
assert len(remaining_relations) == 0
# Verify target entity still exists. Runs outside the session block above:
# find_by_id opens its own scoped session, and the serialized in-memory pool
# (one connection, see #940) deadlocks on nested session checkouts.
target_exists = await entity_repository.find_by_id(target.id)
assert target_exists is not None
# Verify target entity still exists
target_exists = await entity_repository.find_by_id(target.id)
assert target_exists is not None
@pytest.mark.asyncio
-137
View File
@@ -134,115 +134,6 @@ HYBRID_KWARGS: dict[str, Any] = dict(
)
def _hybrid_kwargs(**overrides: Any) -> dict[str, Any]:
"""Return HYBRID_KWARGS with overrides applied, typed as dict[str, Any].
Keeps the splat into the keyword-only _search_hybrid signature type-clean.
"""
merged: dict[str, Any] = {**HYBRID_KWARGS, **overrides}
return merged
@pytest.mark.asyncio
async def test_entity_boost_promotes_matching_doc_when_enabled():
"""With entity boost enabled, an entity-matching doc outranks a higher-similarity
non-matching doc.
Reproduces the #951 cross-conversation confusion: a generic same-topic document
(higher raw similarity) initially outranks the gold doc whose title names the
queried entity. Enabling the boost flips the order.
"""
repo = ConcreteSearchRepo()
repo._entity_boost_enabled = True
repo._entity_boost_weight = 0.15
repo._entity_boost_max_terms = 3
# Row 1: generic hobbies doc from the wrong conversation, higher vector similarity.
# Row 2: the gold doc whose title names the queried entity "Joanna".
fts_results = []
vector_results = [
FakeRow(id=1, score=0.80, title="Hobbies and pastimes"),
FakeRow(id=2, score=0.72, title="Joanna profile"),
]
with (
patch.object(repo, "search", new_callable=AsyncMock, return_value=fts_results),
patch.object(
repo, "_search_vector_only", new_callable=AsyncMock, return_value=vector_results
),
):
results = await repo._search_hybrid(
**_hybrid_kwargs(search_text="What are Joanna's hobbies?")
)
# Boost: row 2 -> 0.72 * 1.15 = 0.828 > row 1's 0.80
assert [r.id for r in results] == [2, 1]
assert results[0].score == pytest.approx(0.72 * 1.15, rel=1e-6)
assert results[1].score == pytest.approx(0.80, rel=1e-6)
@pytest.mark.asyncio
async def test_entity_boost_disabled_preserves_ordering():
"""With entity boost disabled (default), ordering matches pure similarity."""
repo = ConcreteSearchRepo()
# Defaults from the base class keep boosting off; assert explicitly.
assert repo._entity_boost_enabled is False
fts_results = []
vector_results = [
FakeRow(id=1, score=0.80, title="Hobbies and pastimes"),
FakeRow(id=2, score=0.72, title="Joanna profile"),
]
with (
patch.object(repo, "search", new_callable=AsyncMock, return_value=fts_results),
patch.object(
repo, "_search_vector_only", new_callable=AsyncMock, return_value=vector_results
),
):
results = await repo._search_hybrid(
**_hybrid_kwargs(search_text="What are Joanna's hobbies?")
)
# No boost: original similarity order is preserved, scores unchanged.
assert [r.id for r in results] == [1, 2]
assert results[0].score == pytest.approx(0.80, rel=1e-6)
assert results[1].score == pytest.approx(0.72, rel=1e-6)
@pytest.mark.asyncio
async def test_entity_boost_promotes_doc_into_limited_window():
"""Boosting runs before the limit cut, so a matching doc ranked below the cutoff
can be promoted into the returned window."""
repo = ConcreteSearchRepo()
repo._entity_boost_enabled = True
repo._entity_boost_weight = 0.6
repo._entity_boost_max_terms = 3
fts_results = []
# Three non-matching docs above the gold doc, which matches "Anthony".
vector_results = [
FakeRow(id=1, score=0.90, title="conversation six"),
FakeRow(id=2, score=0.85, title="conversation one"),
FakeRow(id=3, score=0.60, title="Anthony introduces himself"),
]
with (
patch.object(repo, "search", new_callable=AsyncMock, return_value=fts_results),
patch.object(
repo, "_search_vector_only", new_callable=AsyncMock, return_value=vector_results
),
):
results = await repo._search_hybrid(
**_hybrid_kwargs(search_text="Who is Anthony?", limit=1)
)
# Gold doc boost: 0.60 * 1.6 = 0.96 > row 1's 0.90, so it is promoted into the
# top-1 window even though it was ranked third before boosting.
assert len(results) == 1
assert results[0].id == 3
@pytest.mark.asyncio
async def test_high_fts_score_boosts_ranking():
"""FTS-only: a high normalized score should outscore a low normalized score."""
@@ -337,34 +228,6 @@ async def test_zero_score_produces_zero_fused():
assert results[0].score == pytest.approx(0.0, rel=1e-6)
@pytest.mark.asyncio
async def test_cross_type_id_collision_keeps_both_results():
"""An entity and a relation sharing the same numeric id stay distinct (#982).
search_index row types have independent id sequences, so fusing on a bare
row id merged unrelated rows into one result and dropped the other.
"""
repo = ConcreteSearchRepo()
fts_results = [FakeRow(id=1, type="entity", score=5.0, title="entity-row")]
vector_results = [FakeRow(id=1, type="relation", score=0.8, title="relation-row")]
with (
patch.object(repo, "search", new_callable=AsyncMock, return_value=fts_results),
patch.object(
repo, "_search_vector_only", new_callable=AsyncMock, return_value=vector_results
),
):
results = await repo._search_hybrid(**HYBRID_KWARGS)
assert {(r.type, r.id) for r in results} == {("entity", 1), ("relation", 1)}
# Single-source scores must not earn the dual-source fusion bonus across types.
entity_result = next(r for r in results if r.type == "entity")
relation_result = next(r for r in results if r.type == "relation")
assert entity_result.score == pytest.approx(1.0, rel=1e-6)
assert relation_result.score == pytest.approx(0.8, rel=1e-6)
@pytest.mark.asyncio
async def test_fts_only_result_gets_matched_chunk_from_content_snippet():
"""FTS-only results should have matched_chunk_text populated from content_snippet."""

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