mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d175879940 | |||
| de8eac6638 | |||
| 9fac9a9416 | |||
| d46c68806e | |||
| 1d9ff3c94f | |||
| 515b2c8365 | |||
| 32a1c208b3 | |||
| 25732b2fe2 | |||
| b92b0340d5 | |||
| 0247ef0ead | |||
| 253e240d68 | |||
| b3bdd5914f | |||
| 85c701b8c2 | |||
| 1ad3a350ad | |||
| a148e72f56 | |||
| 33e741fd29 | |||
| c9770375e9 | |||
| 0811c48252 | |||
| 747e64e5e9 | |||
| be00df27c7 | |||
| 3ce42de57e | |||
| a143072d35 | |||
| 497a4e0a43 | |||
| ec5fac8d76 | |||
| 05bfd0f04e | |||
| 338f357f55 |
@@ -28,7 +28,10 @@ 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
|
||||
- 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)
|
||||
- Entry includes all major features and fixes
|
||||
- Breaking changes are documented
|
||||
|
||||
@@ -41,11 +44,15 @@ just release <version>
|
||||
The justfile target handles:
|
||||
- ✅ Version format validation
|
||||
- ✅ Git status and branch checks
|
||||
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
|
||||
- ✅ Changelog entry check (must already be on `main`)
|
||||
- ✅ Quality checks (`just lint` + `just typecheck`)
|
||||
- ✅ Version update across all consolidated manifests via `just set-version` (Python
|
||||
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
|
||||
- ✅ Automatic commit with proper message
|
||||
- ✅ Tag creation and pushing to GitHub
|
||||
- ✅ 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
|
||||
- ✅ Release workflow trigger (automatic on tag push)
|
||||
|
||||
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
|
||||
@@ -89,7 +96,7 @@ After PyPI release is published, update the MCP registry:
|
||||
|
||||
2. **Publish to MCP Registry**
|
||||
```bash
|
||||
cd /Users/drew/code/basic-memory
|
||||
# from the basic-memory repo root
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
@@ -109,7 +116,7 @@ After PyPI release is published, update the MCP registry:
|
||||
|
||||
#### Website Updates
|
||||
|
||||
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
|
||||
**1. basicmachines.co** (sibling `basicmachines.co` repo)
|
||||
- **Goal**: Update version number displayed on the homepage
|
||||
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
|
||||
- **What to update**:
|
||||
@@ -126,26 +133,31 @@ 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** (`/Users/drew/code/docs.basicmemory.com`)
|
||||
- **Goal**: Add new release notes section to the latest-releases page
|
||||
- **File**: `src/pages/latest-releases.mdx`
|
||||
**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".
|
||||
- **What to do**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
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
|
||||
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
|
||||
|
||||
**4. Announce Release**
|
||||
- Post to Discord community if significant changes
|
||||
|
||||
@@ -300,7 +300,9 @@ See SPEC-16 for full context manager refactor details.
|
||||
|
||||
### Release Process
|
||||
|
||||
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
|
||||
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).
|
||||
|
||||
**Stable release:**
|
||||
|
||||
@@ -308,7 +310,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`, 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.
|
||||
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.
|
||||
|
||||
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
|
||||
|
||||
@@ -319,7 +321,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 notes to `src/pages/latest-releases.mdx`
|
||||
- `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)
|
||||
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
|
||||
- MCP Registry — `mcp-publisher publish` from the repo root
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ retention).
|
||||
| `BASIC_MEMORY_IMPORT_UPLOAD_MAX_BYTES` | `104857600` | Max uploaded import size |
|
||||
|
||||
```bash
|
||||
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
|
||||
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory reindex
|
||||
tail -f ~/.basic-memory/basic-memory.log
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -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 sync
|
||||
docker exec basic-memory-server basic-memory reindex
|
||||
|
||||
# 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 sync
|
||||
docker exec basic-memory-server basic-memory reindex
|
||||
```
|
||||
|
||||
### 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 sync
|
||||
docker exec basic-memory-server basic-memory reindex
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -184,8 +184,8 @@ finance/ (lowercase f)
|
||||
Use Basic Memory's built-in conflict detection:
|
||||
|
||||
```bash
|
||||
# Sync will report conflicts
|
||||
basic-memory sync
|
||||
# Index local file changes (conflicts are handled during the scan)
|
||||
basic-memory reindex
|
||||
|
||||
# Check sync status for warnings
|
||||
basic-memory status
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
-1333
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,76 @@ 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
|
||||
|
||||
|
||||
@@ -383,17 +383,31 @@ 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}}"
|
||||
|
||||
# Commit version update
|
||||
# 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}}"
|
||||
git add \
|
||||
src/basic_memory/__init__.py \
|
||||
server.json \
|
||||
@@ -404,22 +418,56 @@ release version:
|
||||
integrations/hermes/plugin.yaml \
|
||||
integrations/hermes/__init__.py \
|
||||
integrations/openclaw/package.json
|
||||
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 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 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 release notes to src/pages/latest-releases.mdx"
|
||||
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 " 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"
|
||||
@@ -467,7 +515,15 @@ beta version:
|
||||
echo "📝 Updating consolidated package versions..."
|
||||
just set-version "{{version}}"
|
||||
|
||||
# Commit version update
|
||||
# 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}}"
|
||||
git add \
|
||||
src/basic_memory/__init__.py \
|
||||
server.json \
|
||||
@@ -478,23 +534,57 @@ beta version:
|
||||
integrations/hermes/plugin.yaml \
|
||||
integrations/hermes/__init__.py \
|
||||
integrations/openclaw/package.json
|
||||
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 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 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 release notes to src/pages/latest-releases.mdx"
|
||||
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 " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
|
||||
echo " See: .claude/commands/release/release.md for detailed instructions"
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,726 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate MCP tool reference documentation from tool docstrings.
|
||||
|
||||
Introspects all MCP tool definitions in src/basic_memory/mcp/tools/,
|
||||
extracts names, descriptions, parameters, and docstrings, then emits
|
||||
a single deterministic markdown reference to docs/mcp-tools.md.
|
||||
|
||||
Usage:
|
||||
uv run scripts/generate_tool_docs.py
|
||||
|
||||
# Verify idempotency (diff should be empty):
|
||||
uv run scripts/generate_tool_docs.py && uv run scripts/generate_tool_docs.py
|
||||
git diff docs/mcp-tools.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLS_DIR = ROOT / "src" / "basic_memory" / "mcp" / "tools"
|
||||
OUT_FILE = ROOT / "docs" / "mcp-tools.md"
|
||||
|
||||
# Only files that contain public MCP tools (skip helpers/internals).
|
||||
# NOTE: ui_sdk.py is intentionally omitted — its two tools are commented out
|
||||
# in src/basic_memory/mcp/__init__.py and therefore not public.
|
||||
TOOL_FILES = [
|
||||
"build_context.py",
|
||||
"canvas.py",
|
||||
"chatgpt_tools.py",
|
||||
"cloud_info.py",
|
||||
"delete_note.py",
|
||||
"edit_note.py",
|
||||
"list_directory.py",
|
||||
"move_note.py",
|
||||
"project_management.py",
|
||||
"read_content.py",
|
||||
"read_note.py",
|
||||
"recent_activity.py",
|
||||
"release_notes.py",
|
||||
"schema.py",
|
||||
"search.py",
|
||||
"view_note.py",
|
||||
"workspaces.py",
|
||||
"write_note.py",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST-based extraction (no import side-effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_docstring(raw: str | None) -> str:
|
||||
"""Dedent and strip a raw docstring."""
|
||||
if not raw:
|
||||
return ""
|
||||
return inspect.cleandoc(raw)
|
||||
|
||||
|
||||
def _get_default_repr(node: ast.expr | None) -> str:
|
||||
"""Return a short string representation for a default-value AST node."""
|
||||
if node is None:
|
||||
return ""
|
||||
try:
|
||||
return ast.unparse(node)
|
||||
except Exception:
|
||||
return "..."
|
||||
|
||||
|
||||
def _unwrap_annotated(text: str) -> str:
|
||||
"""Bracket-aware unwrap of ``Annotated[X, ...]`` → ``X``.
|
||||
|
||||
The naïve regex ``Annotated\\[([^,\\]]+),.*?\\]`` breaks on types whose
|
||||
first argument itself contains brackets or commas, e.g.
|
||||
``Annotated[Dict[str, Any] | None, ...]`` or
|
||||
``Annotated[List[str] | None, BeforeValidator(...), ...]``.
|
||||
This function scans character-by-character to find the first
|
||||
*top-level* comma that separates the type from the metadata args,
|
||||
then discards everything from that comma to the matching closing ``]``.
|
||||
"""
|
||||
prefix = "Annotated["
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i:].startswith(prefix):
|
||||
# Walk past "Annotated[" and collect the first top-level argument.
|
||||
j = i + len(prefix)
|
||||
depth = 0
|
||||
start = j
|
||||
while j < len(text):
|
||||
ch = text[j]
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
if depth == 0:
|
||||
# Closing bracket of Annotated[...] with no comma found
|
||||
# (shouldn't happen in valid annotations, but be safe).
|
||||
break
|
||||
depth -= 1
|
||||
elif ch == "," and depth == 0:
|
||||
# Found the separator between the type arg and metadata.
|
||||
break
|
||||
j += 1
|
||||
inner_type = text[start:j]
|
||||
result.append(inner_type)
|
||||
# Skip past the rest of the Annotated[...] construct.
|
||||
# We need to find the matching ']' for the original 'Annotated['.
|
||||
depth = 0
|
||||
while j < len(text):
|
||||
ch = text[j]
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch == "]" and depth == 0:
|
||||
j += 1 # consume the closing ']'
|
||||
break
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
i = j
|
||||
else:
|
||||
result.append(text[i])
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _annotation_repr(node: ast.expr | None) -> str:
|
||||
"""Return a readable string for a type-annotation AST node."""
|
||||
if node is None:
|
||||
return ""
|
||||
try:
|
||||
text = ast.unparse(node)
|
||||
except Exception:
|
||||
return ""
|
||||
# Unwrap Annotated[X, ...] → X (keeps output readable).
|
||||
# Use a bracket-aware unwrap so types like Dict[str, Any] | None are
|
||||
# preserved correctly instead of being truncated at the first comma.
|
||||
text = _unwrap_annotated(text)
|
||||
return text
|
||||
|
||||
|
||||
_SENTINEL = object() # returned when decorator IS @mcp.tool but has no description string
|
||||
|
||||
|
||||
def _mcp_tool_description(decorator: ast.expr) -> str | None | object:
|
||||
"""Extract the ``description=`` keyword from an ``@mcp.tool(...)`` decorator call.
|
||||
|
||||
Returns:
|
||||
- A string when the decorator carries ``description="..."``
|
||||
- ``_SENTINEL`` when the decorator IS an mcp.tool call but has no description
|
||||
- ``None`` when the decorator is not an mcp.tool call at all
|
||||
|
||||
This distinction lets callers treat mcp.tool-decorated functions without a
|
||||
decorator description string as still valid tools (description comes from the
|
||||
docstring in that case).
|
||||
"""
|
||||
if not isinstance(decorator, ast.Call):
|
||||
return None
|
||||
|
||||
func = decorator.func
|
||||
# Accept both ``mcp.tool(...)`` and ``tool(...)``
|
||||
if not (
|
||||
(isinstance(func, ast.Attribute) and func.attr == "tool")
|
||||
or (isinstance(func, ast.Name) and func.id == "tool")
|
||||
):
|
||||
return None
|
||||
|
||||
for kw in decorator.keywords:
|
||||
if kw.arg == "description" and isinstance(kw.value, ast.Constant):
|
||||
return kw.value.value
|
||||
|
||||
# Also accept a positional string as the tool name (no description)
|
||||
# e.g. @mcp.tool("list_memory_projects", annotations={...})
|
||||
# In this case, the description is not in the decorator; fall through to docstring.
|
||||
return _SENTINEL
|
||||
|
||||
|
||||
class ToolInfo:
|
||||
"""Holds extracted metadata for a single MCP tool."""
|
||||
|
||||
__slots__ = (
|
||||
"name",
|
||||
"decorator_description",
|
||||
"docstring",
|
||||
"params",
|
||||
"source_file",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
decorator_description: str,
|
||||
docstring: str,
|
||||
params: list[dict[str, str]],
|
||||
source_file: str,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.decorator_description = decorator_description
|
||||
self.docstring = docstring
|
||||
self.params = params
|
||||
self.source_file = source_file
|
||||
|
||||
|
||||
# Parameters that are MCP/framework plumbing — not useful to document.
|
||||
_SKIP_PARAMS = frozenset({"context", "self", "cls"})
|
||||
|
||||
|
||||
def extract_tools_from_file(path: Path) -> list[ToolInfo]:
|
||||
"""Parse *path* with AST and return a list of ToolInfo for every @mcp.tool function."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
except SyntaxError as exc:
|
||||
print(f"WARNING: could not parse {path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
tools: list[ToolInfo] = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
|
||||
# Only process functions that have an @mcp.tool (or @tool) decorator.
|
||||
# decorator_desc is:
|
||||
# - a non-empty string when found in the decorator
|
||||
# - _SENTINEL when the decorator is mcp.tool but carries no description
|
||||
# - None when no mcp.tool decorator is present
|
||||
decorator_desc: str | object | None = None
|
||||
for dec in node.decorator_list:
|
||||
result = _mcp_tool_description(dec)
|
||||
if result is not None:
|
||||
decorator_desc = result
|
||||
break
|
||||
|
||||
if decorator_desc is None:
|
||||
continue
|
||||
|
||||
# When the decorator doesn't carry a description string, fall back to
|
||||
# the first sentence of the docstring (populated below).
|
||||
decorator_has_description = isinstance(decorator_desc, str)
|
||||
|
||||
docstring = _clean_docstring(ast.get_docstring(node))
|
||||
|
||||
# --- Parameters ---
|
||||
params: list[dict[str, str]] = []
|
||||
args = node.args
|
||||
# Defaults are right-aligned against the arg list
|
||||
n_defaults = len(args.defaults)
|
||||
n_args = len(args.args)
|
||||
defaults_padded = [None] * (n_args - n_defaults) + list(args.defaults) # type: ignore[list-item]
|
||||
kw_defaults = args.kw_defaults # may contain None for "no default"
|
||||
|
||||
for i, arg in enumerate(args.args):
|
||||
if arg.arg in _SKIP_PARAMS:
|
||||
continue
|
||||
default = defaults_padded[i]
|
||||
params.append(
|
||||
{
|
||||
"name": arg.arg,
|
||||
"type": _annotation_repr(arg.annotation),
|
||||
"default": _get_default_repr(default) if default is not None else "",
|
||||
}
|
||||
)
|
||||
|
||||
for i, arg in enumerate(args.kwonlyargs):
|
||||
if arg.arg in _SKIP_PARAMS:
|
||||
continue
|
||||
default = kw_defaults[i] if i < len(kw_defaults) else None
|
||||
params.append(
|
||||
{
|
||||
"name": arg.arg,
|
||||
"type": _annotation_repr(arg.annotation),
|
||||
"default": _get_default_repr(default) if default is not None else "",
|
||||
}
|
||||
)
|
||||
|
||||
# Build the short one-liner description:
|
||||
# - prefer the explicit decorator description=
|
||||
# - fall back to the first non-blank line of the docstring
|
||||
if decorator_has_description:
|
||||
short_desc = decorator_desc.strip() # type: ignore[union-attr]
|
||||
else:
|
||||
first_line = next((ln.strip() for ln in docstring.splitlines() if ln.strip()), "")
|
||||
short_desc = first_line
|
||||
|
||||
tools.append(
|
||||
ToolInfo(
|
||||
name=node.name,
|
||||
decorator_description=short_desc,
|
||||
docstring=docstring,
|
||||
params=params,
|
||||
source_file=path.name,
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _escape_table_cell(text: str) -> str:
|
||||
"""Escape ``|`` characters so they do not break GitHub-flavoured markdown tables."""
|
||||
return text.replace("|", "\\|")
|
||||
|
||||
|
||||
def _params_table(
|
||||
params: list[dict[str, str]], arg_descriptions: dict[str, str] | None = None
|
||||
) -> str:
|
||||
"""Render parameters as a markdown table.
|
||||
|
||||
``arg_descriptions`` maps parameter name → one-line description extracted
|
||||
from the docstring ``Args:`` block.
|
||||
"""
|
||||
if not params:
|
||||
return ""
|
||||
arg_descriptions = arg_descriptions or {}
|
||||
rows = [
|
||||
"| Parameter | Type | Default | Description |",
|
||||
"|-----------|------|---------|-------------|",
|
||||
]
|
||||
for p in params:
|
||||
name = f"`{p['name']}`"
|
||||
# Pipe characters inside a table cell break GitHub's renderer; escape them.
|
||||
raw_type = p["type"]
|
||||
typ = f"`{_escape_table_cell(raw_type)}`" if raw_type else ""
|
||||
default_val = p["default"]
|
||||
default = f"`{_escape_table_cell(default_val)}`" if default_val else "*(required)*"
|
||||
desc = _escape_table_cell(arg_descriptions.get(p["name"], ""))
|
||||
rows.append(f"| {name} | {typ} | {default} | {desc} |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _extract_examples_section(docstring: str) -> tuple[str, str]:
|
||||
"""Split docstring into (body_without_examples, examples_block).
|
||||
|
||||
Looks for an 'Examples:' section (with or without leading '#') and
|
||||
returns everything before it as the body, and everything from the
|
||||
Examples header to the next top-level section (e.g. 'Raises:',
|
||||
'Returns:') as examples. Trailing sections after Examples are
|
||||
re-appended to the body so they are not silently dropped.
|
||||
"""
|
||||
# Match headings like "Examples:", "# Examples", "## Examples", etc.
|
||||
example_pattern = re.compile(r"^(#{1,3}\s*)?Examples:?\s*$", re.MULTILINE | re.IGNORECASE)
|
||||
match = example_pattern.search(docstring)
|
||||
if not match:
|
||||
return docstring.strip(), ""
|
||||
|
||||
body_before = docstring[: match.start()].strip()
|
||||
after_header = docstring[match.end() :]
|
||||
|
||||
# Find the next top-level section that is NOT indented (Raises:, Returns:, etc.)
|
||||
# so we do not swallow it into the examples block.
|
||||
next_section = re.search(r"\n(?=[A-Z][^\n]*:$)", after_header, re.MULTILINE)
|
||||
if next_section:
|
||||
examples_raw = after_header[: next_section.start()].strip()
|
||||
trailing = after_header[next_section.start() :].strip()
|
||||
# Re-attach the trailing section to the body.
|
||||
body = (body_before + "\n\n" + trailing).strip() if trailing else body_before
|
||||
else:
|
||||
examples_raw = after_header.strip()
|
||||
body = body_before
|
||||
|
||||
return body, examples_raw
|
||||
|
||||
|
||||
def _parse_args_block(args_text: str) -> dict[str, str]:
|
||||
"""Parse a docstring ``Args:`` block into a ``{param_name: description}`` dict.
|
||||
|
||||
Handles the standard Google-style format with optional leading indentation::
|
||||
|
||||
param_name: First line of description.
|
||||
Continuation lines are more deeply indented.
|
||||
|
||||
Works with both un-indented and uniformly-indented blocks (e.g. when the
|
||||
raw docstring is already cleandoc'd but the Args entries still have a
|
||||
consistent leading indent from the original source indentation).
|
||||
|
||||
Returns a dict mapping each parameter name to its single-line summary
|
||||
(first line only, stripped) for use in the parameters table.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
|
||||
# Determine the base indent level: the minimum indent of non-empty lines.
|
||||
# Parameter entries sit at this level; continuation lines are deeper.
|
||||
non_empty = [ln for ln in args_text.splitlines() if ln.strip()]
|
||||
if not non_empty:
|
||||
return result
|
||||
base_indent = min(len(ln) - len(ln.lstrip()) for ln in non_empty)
|
||||
|
||||
current_name: str | None = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in args_text.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip())
|
||||
stripped = line.strip()
|
||||
# A new parameter entry sits at the base indent level with "name: ..."
|
||||
if indent == base_indent:
|
||||
top_level = re.match(r"(\w+)\s*:(.*)", stripped)
|
||||
if top_level:
|
||||
if current_name is not None:
|
||||
result[current_name] = " ".join(current_lines).strip()
|
||||
current_name = top_level.group(1)
|
||||
rest = top_level.group(2).strip()
|
||||
current_lines = [rest] if rest else []
|
||||
continue
|
||||
# Continuation line — only capture the first continuation line to
|
||||
# keep descriptions terse enough for a table cell.
|
||||
if current_name is not None and not current_lines:
|
||||
current_lines.append(stripped)
|
||||
|
||||
if current_name is not None:
|
||||
result[current_name] = " ".join(current_lines).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _format_args_section(docstring: str) -> tuple[str, dict[str, str]]:
|
||||
"""Remove the 'Args:' block from a docstring and return (cleaned_body, arg_descriptions).
|
||||
|
||||
``arg_descriptions`` maps each parameter name to its one-line description
|
||||
so callers can populate the Description column of the params table.
|
||||
"""
|
||||
pattern = re.compile(r"^Args:\s*$", re.MULTILINE)
|
||||
match = pattern.search(docstring)
|
||||
if not match:
|
||||
return docstring.strip(), {}
|
||||
|
||||
before = docstring[: match.start()].strip()
|
||||
after_start = match.end()
|
||||
|
||||
# The Args block ends at the next top-level section (line that starts
|
||||
# without indentation and ends with ':'), or at end-of-string.
|
||||
next_section = re.search(r"\n(?=[A-Z][^\n:]*:$)", docstring[after_start:], re.MULTILINE)
|
||||
if next_section:
|
||||
# Use lstrip("\n") not strip() so that leading spaces (indent) on the
|
||||
# first arg line are preserved for _parse_args_block's indent detection.
|
||||
args_text = docstring[after_start : after_start + next_section.start()].lstrip("\n")
|
||||
remainder = docstring[after_start + next_section.start() :].strip()
|
||||
body = (before + "\n\n" + remainder).strip()
|
||||
else:
|
||||
args_text = docstring[after_start:].lstrip("\n")
|
||||
body = before.strip()
|
||||
|
||||
return body, _parse_args_block(args_text)
|
||||
|
||||
|
||||
def render_tool_section(tool: ToolInfo, anchor_suffix: str = "") -> str:
|
||||
"""Return a markdown section for a single tool.
|
||||
|
||||
``anchor_suffix`` is appended to the HTML id anchor injected before the
|
||||
heading when the tool name would collide with a group-section anchor
|
||||
(e.g. the ``search`` tool inside the ``## Search`` group).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if anchor_suffix:
|
||||
# Inject an explicit anchor so the TOC link resolves to the tool
|
||||
# section rather than the same-named group header.
|
||||
lines.append(f'<a id="{tool.name.replace("_", "-")}{anchor_suffix}"></a>')
|
||||
lines.append("")
|
||||
lines.append(f"### `{tool.name}`")
|
||||
lines.append("")
|
||||
|
||||
# One-liner description from the decorator (most concise)
|
||||
lines.append(tool.decorator_description)
|
||||
lines.append("")
|
||||
|
||||
# Full docstring body (strip Args: and Examples: sub-sections which get
|
||||
# their own formatting below)
|
||||
doc = tool.docstring
|
||||
doc, arg_descriptions = _format_args_section(doc)
|
||||
doc, examples_text = _extract_examples_section(doc)
|
||||
|
||||
# Avoid duplicating the decorator description: if the docstring's first
|
||||
# non-blank line is essentially the same sentence, drop it.
|
||||
if doc:
|
||||
doc_lines = doc.splitlines()
|
||||
first_nonempty_idx = next(
|
||||
(i for i, ln in enumerate(doc_lines) if ln.strip()),
|
||||
None,
|
||||
)
|
||||
if first_nonempty_idx is not None:
|
||||
first_line = doc_lines[first_nonempty_idx].strip()
|
||||
# Normalise both sides for comparison (lowercase, strip punctuation)
|
||||
norm = lambda s: re.sub(r"[^a-z0-9]", "", s.lower()) # noqa: E731
|
||||
if norm(first_line) == norm(tool.decorator_description):
|
||||
doc_lines.pop(first_nonempty_idx)
|
||||
doc = "\n".join(doc_lines).strip()
|
||||
|
||||
# Demote any `###` sub-headers in the body to `####` so they do not
|
||||
# collide with tool-name headers in GitHub's outline sidebar.
|
||||
doc = re.sub(r"^### ", "#### ", doc, flags=re.MULTILINE)
|
||||
|
||||
if doc:
|
||||
lines.append(doc)
|
||||
lines.append("")
|
||||
|
||||
# Parameters table
|
||||
if tool.params:
|
||||
lines.append("**Parameters**")
|
||||
lines.append("")
|
||||
lines.append(_params_table(tool.params, arg_descriptions))
|
||||
lines.append("")
|
||||
|
||||
# Examples block
|
||||
if examples_text:
|
||||
lines.append("**Examples**")
|
||||
lines.append("")
|
||||
# Wrap in a python code fence if not already fenced
|
||||
if "```" not in examples_text:
|
||||
lines.append("```python")
|
||||
lines.append(examples_text)
|
||||
lines.append("```")
|
||||
else:
|
||||
lines.append(examples_text)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"*Source: `src/basic_memory/mcp/tools/{tool.source_file}`*")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Stable, hand-curated grouping of tools into logical categories.
|
||||
# Tools not listed here fall into "Other Tools".
|
||||
TOOL_GROUPS: dict[str, list[str]] = {
|
||||
"Note Management": [
|
||||
"write_note",
|
||||
"read_note",
|
||||
"view_note",
|
||||
"edit_note",
|
||||
"move_note",
|
||||
"delete_note",
|
||||
],
|
||||
"Reading & Navigation": [
|
||||
"read_content",
|
||||
"build_context",
|
||||
"recent_activity",
|
||||
"list_directory",
|
||||
],
|
||||
"Search": [
|
||||
"search_notes",
|
||||
"search",
|
||||
"fetch",
|
||||
],
|
||||
"Project & Workspace Management": [
|
||||
"list_memory_projects",
|
||||
"create_memory_project",
|
||||
"delete_project",
|
||||
"list_workspaces",
|
||||
],
|
||||
"Schema Tools": [
|
||||
"schema_validate",
|
||||
"schema_infer",
|
||||
"schema_diff",
|
||||
],
|
||||
"Visualization": [
|
||||
"canvas",
|
||||
],
|
||||
"Info & Utilities": [
|
||||
"cloud_info",
|
||||
"release_notes",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def group_tools(tools: list[ToolInfo]) -> dict[str, list[ToolInfo]]:
|
||||
"""Return an ordered dict mapping group name → list of ToolInfo."""
|
||||
by_name: dict[str, ToolInfo] = {t.name: t for t in tools}
|
||||
result: dict[str, list[ToolInfo]] = {}
|
||||
|
||||
placed: set[str] = set()
|
||||
for group, names in TOOL_GROUPS.items():
|
||||
members = [by_name[n] for n in names if n in by_name]
|
||||
if members:
|
||||
result[group] = members
|
||||
placed.update(n for n in names if n in by_name)
|
||||
|
||||
# Anything not in TOOL_GROUPS goes to "Other Tools", sorted alphabetically
|
||||
other = sorted([t for t in tools if t.name not in placed], key=lambda t: t.name)
|
||||
if other:
|
||||
result["Other Tools"] = other
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level document builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HEADER = """\
|
||||
<!--
|
||||
This file is AUTO-GENERATED. Do not edit it by hand.
|
||||
|
||||
To regenerate:
|
||||
uv run scripts/generate_tool_docs.py
|
||||
|
||||
Source: scripts/generate_tool_docs.py
|
||||
-->
|
||||
|
||||
# Basic Memory MCP Tool Reference
|
||||
|
||||
Complete reference for all MCP tools exposed by the Basic Memory server.
|
||||
Tools are grouped by function. Parameters marked *(required)* have no default value.
|
||||
|
||||
> **Regenerating this file**: run `uv run scripts/generate_tool_docs.py` from the
|
||||
> repository root. The output is deterministic; running it twice should produce
|
||||
> an identical file (zero diff).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def _tool_anchor(tool_name: str, group_anchors: set[str]) -> str:
|
||||
"""Return a stable GitHub anchor for a tool heading.
|
||||
|
||||
If the plain ``tool_name`` (with ``_`` → ``-``) would collide with a
|
||||
group-section anchor (e.g. the ``search`` tool inside the ``## Search``
|
||||
group), append ``-tool`` to disambiguate.
|
||||
"""
|
||||
base = tool_name.replace("_", "-")
|
||||
if base in group_anchors:
|
||||
return base + "-tool"
|
||||
return base
|
||||
|
||||
|
||||
def build_toc(groups: dict[str, list[ToolInfo]]) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
# Collect all group anchors first so tool anchors can avoid collisions.
|
||||
group_anchors: set[str] = set()
|
||||
for group in groups:
|
||||
anchor = re.sub(r"[^\w\s-]", "", group.lower())
|
||||
anchor = re.sub(r"\s+", "-", anchor.strip())
|
||||
group_anchors.add(anchor)
|
||||
|
||||
for group, members in groups.items():
|
||||
# GitHub-style anchor: lowercase, spaces → hyphens, drop punctuation
|
||||
anchor = re.sub(r"[^\w\s-]", "", group.lower())
|
||||
anchor = re.sub(r"\s+", "-", anchor.strip())
|
||||
lines.append(f"- [{group}](#{anchor})")
|
||||
for tool in members:
|
||||
t_anchor = _tool_anchor(tool.name, group_anchors)
|
||||
lines.append(f" - [`{tool.name}`](#{t_anchor})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_document(groups: dict[str, list[ToolInfo]]) -> str:
|
||||
# Pre-compute group anchors so tool-section rendering can inject explicit
|
||||
# HTML id attributes where tool names would collide with group headers.
|
||||
group_anchors: set[str] = set()
|
||||
for group in groups:
|
||||
anchor = re.sub(r"[^\w\s-]", "", group.lower())
|
||||
anchor = re.sub(r"\s+", "-", anchor.strip())
|
||||
group_anchors.add(anchor)
|
||||
|
||||
parts: list[str] = [HEADER]
|
||||
parts.append(build_toc(groups))
|
||||
parts.append("\n\n---\n")
|
||||
|
||||
for group, members in groups.items():
|
||||
parts.append(f"\n## {group}\n")
|
||||
for tool in members:
|
||||
base = tool.name.replace("_", "-")
|
||||
suffix = "-tool" if base in group_anchors else ""
|
||||
parts.append(render_tool_section(tool, anchor_suffix=suffix))
|
||||
parts.append("---\n")
|
||||
|
||||
# Trailing newline
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
all_tools: list[ToolInfo] = []
|
||||
for filename in TOOL_FILES:
|
||||
path = TOOLS_DIR / filename
|
||||
if not path.exists():
|
||||
print(f"WARNING: {path} does not exist — skipping", file=sys.stderr)
|
||||
continue
|
||||
file_tools = extract_tools_from_file(path)
|
||||
all_tools.extend(file_tools)
|
||||
|
||||
if not all_tools:
|
||||
print("ERROR: no tools found — check TOOLS_DIR path", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Stable sort: group order is defined by TOOL_GROUPS; within groups, order
|
||||
# is defined by TOOL_GROUPS list. Global sort here is just for determinism
|
||||
# of "Other Tools".
|
||||
groups = group_tools(all_tools)
|
||||
document = build_document(groups)
|
||||
|
||||
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_FILE.write_text(document, encoding="utf-8")
|
||||
|
||||
total = sum(len(m) for m in groups.values())
|
||||
print(
|
||||
f"Generated {OUT_FILE.relative_to(ROOT)} ({total} tools, {len(OUT_FILE.read_text().splitlines())} lines)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -193,7 +193,7 @@ async def add_project(
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
default=new_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=new_project.id,
|
||||
external_id=new_project.external_id,
|
||||
@@ -320,7 +320,20 @@ async def resolve_project_identifier(
|
||||
)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
||||
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)
|
||||
|
||||
return ProjectResolveResponse(
|
||||
external_id=project.external_id,
|
||||
@@ -559,12 +572,10 @@ 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
|
||||
# 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.
|
||||
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)
|
||||
@@ -576,17 +587,27 @@ 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)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{new_default_project.name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
# 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(
|
||||
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,
|
||||
|
||||
@@ -147,6 +147,7 @@ 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,
|
||||
|
||||
@@ -83,8 +83,11 @@ 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",
|
||||
|
||||
@@ -168,6 +168,20 @@ 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()
|
||||
@@ -288,6 +302,7 @@ def run_auto_update(
|
||||
else BREW_UPGRADE_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
_preload_lazy_console_modules()
|
||||
install_result = _run_subprocess(
|
||||
command,
|
||||
timeout_seconds=timeout,
|
||||
|
||||
@@ -4,6 +4,7 @@ from . import ci, status, db, doctor, import_memory_json, mcp, import_claude_con
|
||||
from . import (
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
man,
|
||||
tool,
|
||||
project,
|
||||
format,
|
||||
@@ -29,4 +30,5 @@ __all__ = [
|
||||
"schema",
|
||||
"update",
|
||||
"workspace",
|
||||
"man",
|
||||
]
|
||||
|
||||
@@ -34,8 +34,10 @@ 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
|
||||
from basic_memory.mcp.tools import search_notes as mcp_search_notes
|
||||
from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
|
||||
# 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).
|
||||
|
||||
|
||||
console = Console()
|
||||
@@ -252,6 +254,10 @@ 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():
|
||||
@@ -295,6 +301,10 @@ 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,12 +3,10 @@
|
||||
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
|
||||
@@ -31,6 +29,9 @@ 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:
|
||||
@@ -53,6 +54,8 @@ 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
|
||||
@@ -86,6 +89,9 @@ 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)
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.indexing import IndexProgress
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -159,6 +162,13 @@ 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)
|
||||
|
||||
@@ -197,6 +207,12 @@ 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"
|
||||
@@ -320,10 +336,15 @@ async def _reindex(
|
||||
project: str | None,
|
||||
):
|
||||
"""Run reindex operations."""
|
||||
from basic_memory.repository import EntityRepository
|
||||
# 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.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
|
||||
|
||||
|
||||
@@ -7,16 +7,12 @@ 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
|
||||
@@ -29,6 +25,12 @@ 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]}"
|
||||
@@ -140,6 +142,9 @@ 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,25 +1,34 @@
|
||||
"""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 Annotated, Tuple
|
||||
from typing import TYPE_CHECKING, 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)
|
||||
@@ -60,6 +69,9 @@ 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,25 +1,34 @@
|
||||
"""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 Annotated, Tuple
|
||||
from typing import TYPE_CHECKING, 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)
|
||||
@@ -57,6 +66,9 @@ 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,25 +1,34 @@
|
||||
"""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 Annotated, Tuple
|
||||
from typing import TYPE_CHECKING, 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)
|
||||
@@ -56,6 +65,9 @@ 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,25 +1,34 @@
|
||||
"""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 Annotated, Tuple
|
||||
from typing import TYPE_CHECKING, 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)
|
||||
@@ -55,6 +64,9 @@ 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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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]")
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
@@ -50,6 +49,9 @@ 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):
|
||||
|
||||
@@ -20,9 +20,10 @@ 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
|
||||
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
|
||||
|
||||
# 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).
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -189,6 +190,9 @@ 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)
|
||||
@@ -272,6 +276,9 @@ 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)
|
||||
@@ -352,6 +359,9 @@ 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)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -190,9 +189,16 @@ 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"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."
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
@@ -224,6 +230,9 @@ 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
|
||||
|
||||
@@ -14,18 +14,10 @@ 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
|
||||
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
|
||||
|
||||
# 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).
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
|
||||
@@ -120,6 +112,9 @@ 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)
|
||||
|
||||
@@ -206,6 +201,9 @@ 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)
|
||||
|
||||
@@ -272,6 +270,9 @@ 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)
|
||||
|
||||
@@ -346,6 +347,9 @@ 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)
|
||||
|
||||
@@ -413,6 +417,9 @@ 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)
|
||||
|
||||
@@ -476,6 +483,9 @@ 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)
|
||||
|
||||
@@ -582,6 +592,9 @@ 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)
|
||||
|
||||
@@ -687,6 +700,9 @@ 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)
|
||||
|
||||
@@ -720,6 +736,9 @@ 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)
|
||||
|
||||
@@ -772,6 +791,9 @@ 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)
|
||||
|
||||
@@ -840,6 +862,9 @@ 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)
|
||||
|
||||
@@ -896,6 +921,9 @@ 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)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_memory_json,
|
||||
man,
|
||||
mcp,
|
||||
orphans,
|
||||
project,
|
||||
|
||||
@@ -4,6 +4,7 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -345,6 +346,42 @@ 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(
|
||||
@@ -1099,8 +1136,21 @@ 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")
|
||||
file_path.write_text(json.dumps(config_dict, indent=2))
|
||||
_secure_config_file(file_path)
|
||||
# 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)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
|
||||
|
||||
+25
-13
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
async_scoped_session,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
|
||||
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
@@ -216,19 +216,31 @@ 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
|
||||
# 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)
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=NullPool, # Disable connection pooling on Windows
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
|
||||
|
||||
@@ -302,26 +302,76 @@ 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 valid frontmatter markers (---), False otherwise
|
||||
True if content has line-anchored frontmatter fences, False otherwise
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
|
||||
# Strip BOM before checking for frontmatter markers
|
||||
content = strip_bom(content).strip()
|
||||
if not content.startswith("---"):
|
||||
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.
|
||||
return False
|
||||
|
||||
return "---" in content[3:]
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -339,17 +389,14 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
|
||||
try:
|
||||
# Strip BOM before parsing frontmatter
|
||||
content = strip_bom(content)
|
||||
if not content.strip().startswith("---"):
|
||||
split = _split_frontmatter(content)
|
||||
if split is None:
|
||||
raise ParseError("Content has no frontmatter")
|
||||
|
||||
# Split on first two occurrences of ---
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
raise ParseError("Invalid frontmatter format")
|
||||
yaml_block, _ = split
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
frontmatter = yaml.safe_load(yaml_block)
|
||||
# Handle empty frontmatter (None from yaml.safe_load)
|
||||
if frontmatter is None:
|
||||
return {}
|
||||
@@ -381,18 +428,17 @@ def remove_frontmatter(content: str) -> str:
|
||||
ParseError: If content starts with frontmatter marker but is malformed
|
||||
"""
|
||||
# Strip BOM before processing
|
||||
content = strip_bom(content).strip()
|
||||
content = strip_bom(content)
|
||||
|
||||
# Return as-is if no frontmatter marker
|
||||
if not content.startswith("---"):
|
||||
return content
|
||||
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()
|
||||
|
||||
# Split on first two occurrences of ---
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
raise ParseError("Invalid frontmatter format")
|
||||
|
||||
return parts[2].strip()
|
||||
_, body = split
|
||||
return body.strip()
|
||||
|
||||
|
||||
def dump_frontmatter(post: frontmatter.Post) -> str:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.so man1/bm.1
|
||||
@@ -0,0 +1,134 @@
|
||||
.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)
|
||||
@@ -5,14 +5,16 @@ 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
|
||||
from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials
|
||||
|
||||
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]"]
|
||||
@@ -28,8 +30,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:
|
||||
@@ -57,7 +59,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
|
||||
|
||||
@@ -71,7 +73,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)
|
||||
@@ -82,8 +84,10 @@ 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
|
||||
@@ -127,7 +131,7 @@ async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatab
|
||||
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)
|
||||
@@ -139,7 +143,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:
|
||||
@@ -163,7 +167,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):
|
||||
@@ -173,7 +177,7 @@ def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value:
|
||||
|
||||
|
||||
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:
|
||||
@@ -196,7 +200,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:
|
||||
@@ -368,7 +372,8 @@ async def get_client(
|
||||
1. Factory injection.
|
||||
2. Explicit routing flags (--local/--cloud).
|
||||
3. Per-project mode routing when project_name is provided.
|
||||
4. Local ASGI transport by default.
|
||||
4. Cloud routing when a workspace selector is provided.
|
||||
5. Local ASGI transport by default.
|
||||
"""
|
||||
if _client_factory:
|
||||
async with _client_factory(workspace=workspace) as client:
|
||||
@@ -428,6 +433,26 @@ 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:
|
||||
|
||||
@@ -7,7 +7,9 @@ from typing import Optional, Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
# 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).
|
||||
|
||||
|
||||
class DirectoryClient:
|
||||
@@ -55,6 +57,8 @@ 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,
|
||||
|
||||
@@ -8,7 +8,10 @@ from typing import Any
|
||||
from httpx import AsyncClient
|
||||
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
|
||||
|
||||
# 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.schemas.response import (
|
||||
EntityResponse,
|
||||
DeleteEntitiesResponse,
|
||||
@@ -57,6 +60,8 @@ 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",
|
||||
@@ -89,6 +94,8 @@ 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",
|
||||
@@ -116,6 +123,8 @@ 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",
|
||||
@@ -147,6 +156,8 @@ 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",
|
||||
@@ -174,6 +185,8 @@ 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",
|
||||
@@ -201,6 +214,8 @@ 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",
|
||||
@@ -231,6 +246,8 @@ 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",
|
||||
@@ -261,6 +278,8 @@ 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",
|
||||
@@ -290,6 +309,8 @@ 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",
|
||||
@@ -309,6 +330,8 @@ 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",
|
||||
@@ -338,6 +361,8 @@ 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",
|
||||
|
||||
@@ -8,7 +8,10 @@ from typing import Optional
|
||||
from httpx import AsyncClient
|
||||
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
# 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.schemas.memory import GraphContext
|
||||
|
||||
|
||||
@@ -63,6 +66,8 @@ class MemoryClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
params: dict = {
|
||||
"depth": depth,
|
||||
"page": page,
|
||||
@@ -113,6 +118,8 @@ class MemoryClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
params: dict = {
|
||||
"timeframe": timeframe,
|
||||
"depth": depth,
|
||||
|
||||
@@ -7,13 +7,9 @@ from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import (
|
||||
call_delete,
|
||||
call_get,
|
||||
call_patch,
|
||||
call_post,
|
||||
call_put,
|
||||
)
|
||||
# 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.schemas import ProjectInfoResponse, SyncReportResponse
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
@@ -53,6 +49,8 @@ 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/",
|
||||
@@ -71,6 +69,8 @@ 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,6 +93,8 @@ 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"
|
||||
@@ -114,6 +116,8 @@ 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",
|
||||
@@ -133,6 +137,8 @@ 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",
|
||||
@@ -154,6 +160,8 @@ 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}",
|
||||
@@ -181,6 +189,8 @@ 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:
|
||||
@@ -204,6 +214,8 @@ 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",
|
||||
@@ -222,6 +234,8 @@ 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",
|
||||
|
||||
@@ -6,7 +6,9 @@ Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
# 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).
|
||||
|
||||
|
||||
class ResourceClient:
|
||||
@@ -49,6 +51,8 @@ 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",
|
||||
|
||||
@@ -5,7 +5,9 @@ Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
# 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.schemas.schema import (
|
||||
ValidationReport,
|
||||
InferenceReport,
|
||||
@@ -56,6 +58,8 @@ 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
|
||||
@@ -87,6 +91,8 @@ 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",
|
||||
@@ -106,6 +112,8 @@ 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}",
|
||||
|
||||
@@ -8,7 +8,10 @@ from typing import Any
|
||||
from httpx import AsyncClient
|
||||
|
||||
import logfire
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
|
||||
# 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.schemas.search import SearchResponse
|
||||
|
||||
|
||||
@@ -57,6 +60,8 @@ 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",
|
||||
|
||||
@@ -8,10 +8,24 @@ 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
|
||||
@@ -19,8 +33,6 @@ 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
|
||||
@@ -46,6 +58,9 @@ 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,
|
||||
@@ -54,6 +69,14 @@ _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."""
|
||||
@@ -460,6 +483,17 @@ 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,
|
||||
@@ -477,6 +511,24 @@ 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
|
||||
@@ -722,9 +774,15 @@ 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."""
|
||||
if context:
|
||||
"""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:
|
||||
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:
|
||||
@@ -798,13 +856,99 @@ 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.
|
||||
@@ -816,23 +960,14 @@ async def resolve_workspace_project_identifier(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
|
||||
workspace_identifier, project_identifier = _split_qualified_project_identifier(project)
|
||||
project_permalink = generate_permalink(project_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]
|
||||
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)
|
||||
matches = [
|
||||
entry
|
||||
for entry in index.entries_by_permalink.get(project_permalink, ())
|
||||
@@ -843,7 +978,7 @@ async def resolve_workspace_project_identifier(
|
||||
failed_workspace.tenant_id == workspace.tenant_id
|
||||
for failed_workspace in index.failed_workspaces
|
||||
):
|
||||
raise ValueError(
|
||||
raise WorkspaceProjectLookupMiss(
|
||||
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
|
||||
"could not be loaded. Retry after workspace discovery recovers."
|
||||
)
|
||||
@@ -852,7 +987,7 @@ async def resolve_workspace_project_identifier(
|
||||
for entry in index.entries
|
||||
if entry.workspace.tenant_id == workspace.tenant_id
|
||||
)
|
||||
raise ValueError(
|
||||
raise WorkspaceProjectLookupMiss(
|
||||
f"Project '{project_identifier}' was not found in workspace "
|
||||
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
|
||||
)
|
||||
@@ -877,7 +1012,7 @@ async def resolve_workspace_project_identifier(
|
||||
"retry or use a qualified project from an indexed workspace."
|
||||
)
|
||||
available = ", ".join(entry.qualified_name for entry in index.entries)
|
||||
raise ValueError(
|
||||
raise WorkspaceProjectLookupMiss(
|
||||
f"Project '{project}' was not found in indexed cloud workspaces. "
|
||||
f"Available projects: {available}.{failed_note}"
|
||||
)
|
||||
@@ -1212,6 +1347,9 @@ 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(
|
||||
@@ -1438,6 +1576,9 @@ 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""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)
|
||||
@@ -54,7 +54,10 @@ def _format_entity_block(result: ContextResult) -> str:
|
||||
lines.append("")
|
||||
lines.append("### Relations")
|
||||
for rel in relation_items:
|
||||
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
|
||||
# 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}]]")
|
||||
|
||||
# --- Related entities (non-relation related results) ---
|
||||
related_entities: list[EntitySummary | ObservationSummary] = [
|
||||
@@ -112,6 +115,7 @@ 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.
|
||||
@@ -131,6 +135,7 @@ 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(
|
||||
|
||||
@@ -17,7 +17,9 @@ 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,7 +105,9 @@ 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(
|
||||
@@ -167,7 +169,9 @@ 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(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Cloud information MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.mcp.resources.discovery import load_discovery_resource
|
||||
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."""
|
||||
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
|
||||
return content_path.read_text(encoding="utf-8")
|
||||
return load_discovery_resource("cloud_info.md")
|
||||
|
||||
@@ -182,7 +182,9 @@ 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(
|
||||
|
||||
@@ -304,7 +304,9 @@ 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,7 +11,9 @@ 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(
|
||||
|
||||
@@ -357,7 +357,9 @@ 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,6 +357,8 @@ 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(
|
||||
@@ -475,10 +477,14 @@ async def _resolve_workspace_routing(
|
||||
if workspace is None:
|
||||
return None
|
||||
|
||||
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
|
||||
forced_local = _explicit_routing() and _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 (
|
||||
explicit_cloud_routing and has_cloud_credentials(config)
|
||||
has_cloud_credentials(config) and not forced_local
|
||||
)
|
||||
if not should_resolve_workspace:
|
||||
return workspace
|
||||
@@ -495,6 +501,8 @@ 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(
|
||||
@@ -517,8 +525,9 @@ async def create_memory_project(
|
||||
workspace: Optional cloud workspace selector to create the project in. Slug is
|
||||
preferred for AI callers, but tenant_id and unique name are also accepted.
|
||||
When omitted, the connection's default workspace is used. Discover values
|
||||
via `list_workspaces`. In local mode the selector is passed through
|
||||
without slug resolution.
|
||||
via `list_workspaces`. A workspace selector implies cloud routing:
|
||||
without cloud credentials the call fails fast instead of silently
|
||||
creating a local project (#954).
|
||||
output_format: "text" returns the existing human-readable result text.
|
||||
"json" returns structured project creation metadata.
|
||||
context: Optional FastMCP context for progress/status logging.
|
||||
@@ -636,6 +645,8 @@ async def create_memory_project(
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
title="Delete Project",
|
||||
tags={"projects"},
|
||||
annotations={"destructiveHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def delete_project(
|
||||
@@ -654,8 +665,9 @@ 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. In local mode the selector is passed through without slug
|
||||
resolution, matching create_memory_project behavior.
|
||||
used. A workspace selector implies cloud routing: without cloud
|
||||
credentials the call fails fast, matching create_memory_project
|
||||
behavior (#954).
|
||||
|
||||
Returns:
|
||||
Confirmation message about project deletion
|
||||
|
||||
@@ -155,7 +155,9 @@ 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(
|
||||
|
||||
@@ -74,7 +74,9 @@ 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,6 +26,7 @@ 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:
|
||||
@@ -36,6 +37,7 @@ 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(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Release notes MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.mcp.resources.discovery import load_discovery_resource
|
||||
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."""
|
||||
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
|
||||
return content_path.read_text(encoding="utf-8")
|
||||
return load_discovery_resource("release_notes.md")
|
||||
|
||||
@@ -204,7 +204,9 @@ 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(
|
||||
@@ -317,7 +319,9 @@ 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(
|
||||
@@ -438,7 +442,9 @@ 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(
|
||||
|
||||
@@ -14,7 +14,7 @@ from basic_memory.config import ConfigManager, has_cloud_credentials
|
||||
from basic_memory.utils import (
|
||||
build_canonical_permalink,
|
||||
coerce_dict,
|
||||
coerce_list,
|
||||
parse_str_list,
|
||||
parse_tags,
|
||||
strict_search_tags,
|
||||
)
|
||||
@@ -612,7 +612,9 @@ 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},
|
||||
@@ -649,24 +651,30 @@ async def search_notes(
|
||||
# Plural-vs-singular trips models constantly. Accept the singular too.
|
||||
note_types: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
# 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),
|
||||
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(coerce_list),
|
||||
BeforeValidator(parse_str_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.",
|
||||
"'Chapter' here — use note_types instead. "
|
||||
"Accepts a list, a comma-separated string (e.g. 'entity,observation'), or a JSON-array string.",
|
||||
] = None,
|
||||
categories: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
BeforeValidator(parse_str_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,
|
||||
@@ -820,6 +828,11 @@ 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")
|
||||
@@ -893,6 +906,17 @@ 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 []
|
||||
|
||||
@@ -18,7 +18,9 @@ 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},
|
||||
)
|
||||
@@ -92,7 +94,9 @@ 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},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ 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(
|
||||
|
||||
@@ -45,7 +45,9 @@ 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(
|
||||
|
||||
@@ -25,8 +25,39 @@ 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(
|
||||
@@ -38,6 +69,7 @@ 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",
|
||||
@@ -93,6 +125,8 @@ 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().
|
||||
@@ -170,6 +204,11 @@ 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,6 +67,9 @@ 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,6 +36,7 @@ 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)
|
||||
@@ -94,6 +95,7 @@ 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,6 +40,69 @@ 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:
|
||||
@@ -161,6 +224,13 @@ 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.
|
||||
|
||||
@@ -1857,7 +1927,7 @@ class SearchRepositoryBase(ABC):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_chunk_key(chunk_key: str) -> tuple[str, int]:
|
||||
def _parse_chunk_key(chunk_key: str) -> SearchIndexKey:
|
||||
"""Parse a chunk_key like 'observation:5:0' into (type, search_index_id)."""
|
||||
parts = chunk_key.split(":")
|
||||
return parts[0], int(parts[1])
|
||||
@@ -1932,26 +2002,27 @@ 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.
|
||||
# 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).
|
||||
# Track the best similarity per row (for ranking) and all chunks (for context).
|
||||
similarity_by_si_id: dict[int, float] = {}
|
||||
chunks_by_si_id: dict[int, list[tuple[float, str]]] = {}
|
||||
similarity_by_si_key: dict[SearchIndexKey, float] = {}
|
||||
chunks_by_si_key: dict[SearchIndexKey, 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_id = self._parse_chunk_key(chunk_key)
|
||||
si_key = self._parse_chunk_key(chunk_key)
|
||||
except (ValueError, IndexError):
|
||||
# Fallback: group by entity_id for chunks without parseable keys
|
||||
continue
|
||||
current = similarity_by_si_id.get(si_id)
|
||||
current = similarity_by_si_key.get(si_key)
|
||||
if current is None or similarity > current:
|
||||
similarity_by_si_id[si_id] = similarity
|
||||
chunks_by_si_id.setdefault(si_id, []).append((similarity, chunk_text))
|
||||
similarity_by_si_key[si_key] = similarity
|
||||
chunks_by_si_key.setdefault(si_key, []).append((similarity, chunk_text))
|
||||
|
||||
if not similarity_by_si_id:
|
||||
if not similarity_by_si_key:
|
||||
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
|
||||
_log_vector_summary()
|
||||
return []
|
||||
@@ -1962,16 +2033,17 @@ 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_id = {
|
||||
k: v for k, v in similarity_by_si_id.items() if v >= effective_min_similarity
|
||||
similarity_by_si_key = {
|
||||
k: v for k, v in similarity_by_si_key.items() if v >= effective_min_similarity
|
||||
}
|
||||
if not similarity_by_si_id:
|
||||
if not similarity_by_si_key:
|
||||
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
|
||||
_log_vector_summary()
|
||||
return []
|
||||
|
||||
# Fetch the actual search_index rows
|
||||
si_ids = list(similarity_by_si_id.keys())
|
||||
# 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))
|
||||
search_index_rows = await self._fetch_search_index_rows_by_ids(si_ids)
|
||||
|
||||
# Apply optional filters if requested
|
||||
@@ -2003,16 +2075,14 @@ class SearchRepositoryBase(ABC):
|
||||
limit=VECTOR_FILTER_SCAN_LIMIT,
|
||||
offset=0,
|
||||
)
|
||||
# Use (id, type) tuples to avoid collisions between different
|
||||
# Use (type, id) tuples to avoid collisions between different
|
||||
# search_index row types that share the same auto-increment id.
|
||||
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
|
||||
}
|
||||
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}
|
||||
|
||||
ranked_rows: list[SearchIndexRow] = []
|
||||
for si_id, similarity in similarity_by_si_id.items():
|
||||
row = search_index_rows.get(si_id)
|
||||
for si_key, similarity in similarity_by_si_key.items():
|
||||
row = search_index_rows.get(si_key)
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
@@ -2022,7 +2092,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_id.get(si_id, [])
|
||||
si_chunks = chunks_by_si_key.get(si_key, [])
|
||||
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
|
||||
@@ -2088,8 +2158,12 @@ class SearchRepositoryBase(ABC):
|
||||
|
||||
async def _fetch_search_index_rows_by_ids(
|
||||
self, row_ids: list[int]
|
||||
) -> dict[int, SearchIndexRow]:
|
||||
"""Fetch search_index rows by their primary key (id), any type."""
|
||||
) -> 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.
|
||||
"""
|
||||
if not row_ids:
|
||||
return {}
|
||||
placeholders = ",".join(f":id_{idx}" for idx in range(len(row_ids)))
|
||||
@@ -2106,11 +2180,11 @@ class SearchRepositoryBase(ABC):
|
||||
WHERE project_id = :project_id
|
||||
AND id IN ({placeholders})
|
||||
"""
|
||||
result: dict[int, SearchIndexRow] = {}
|
||||
result: dict[SearchIndexKey, 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.id] = SearchIndexRow(
|
||||
result[(row.type, row.id)] = SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
@@ -2138,6 +2212,105 @@ 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,
|
||||
*,
|
||||
@@ -2156,7 +2329,7 @@ class SearchRepositoryBase(ABC):
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Fuse FTS and vector results using score-based fusion.
|
||||
|
||||
Uses search_index row id as the fusion key. The formula
|
||||
Uses the search_index (type, id) pair as the fusion key. The formula
|
||||
``max(vec, fts) + FUSION_BONUS * min(vec, fts)`` preserves
|
||||
the dominant signal and rewards dual-source agreement.
|
||||
"""
|
||||
@@ -2199,17 +2372,19 @@ class SearchRepositoryBase(ABC):
|
||||
vector_ms = (time.perf_counter() - vector_start) * 1000
|
||||
fusion_start = time.perf_counter()
|
||||
|
||||
# --- Score-based fusion keyed on search_index row id ---
|
||||
# --- 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).
|
||||
# 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_id: dict[int, SearchIndexRow] = {}
|
||||
rows_by_key: dict[SearchIndexKey, 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[int, float] = {}
|
||||
fts_scores: dict[SearchIndexKey, float] = {}
|
||||
for row in fts_results:
|
||||
if row.id is None:
|
||||
continue
|
||||
@@ -2217,32 +2392,41 @@ class SearchRepositoryBase(ABC):
|
||||
# Gate: FTS scores below threshold contribute zero
|
||||
if norm < FTS_GATE_THRESHOLD:
|
||||
norm = 0.0
|
||||
fts_scores[row.id] = norm
|
||||
rows_by_id[row.id] = row
|
||||
fts_scores[(row.type, row.id)] = norm
|
||||
rows_by_key[(row.type, row.id)] = row
|
||||
|
||||
vec_scores: dict[int, float] = {}
|
||||
vec_scores: dict[SearchIndexKey, 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.id] = row.score or 0.0
|
||||
rows_by_id[row.id] = row
|
||||
vec_scores[(row.type, row.id)] = row.score or 0.0
|
||||
rows_by_key[(row.type, 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[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)
|
||||
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)
|
||||
|
||||
ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True)
|
||||
output: list[SearchIndexRow] = []
|
||||
for row_id, fused_score in ranked[offset : offset + limit]:
|
||||
row = rows_by_id[row_id]
|
||||
for row_key, fused_score in ranked[offset : offset + limit]:
|
||||
row = rows_by_key[row_key]
|
||||
# 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,6 +55,9 @@ 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()
|
||||
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
|
||||
@@ -92,6 +91,10 @@ 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
|
||||
|
||||
@@ -155,6 +155,9 @@ 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[
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -36,6 +37,7 @@ 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
|
||||
@@ -143,6 +145,29 @@ 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}'")
|
||||
@@ -392,6 +417,7 @@ 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,
|
||||
@@ -426,6 +452,7 @@ 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,
|
||||
@@ -478,6 +505,10 @@ 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,
|
||||
@@ -541,6 +572,7 @@ class ContextService:
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
to_name,
|
||||
content,
|
||||
category,
|
||||
entity_id,
|
||||
@@ -550,7 +582,7 @@ class ContextService:
|
||||
FROM entity_graph
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
relation_type, to_name, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
@@ -579,6 +611,7 @@ 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,
|
||||
@@ -606,6 +639,7 @@ class ContextService:
|
||||
r.from_id,
|
||||
r.to_id,
|
||||
r.relation_type,
|
||||
r.to_name,
|
||||
NULL as content,
|
||||
NULL as category,
|
||||
NULL as entity_id,
|
||||
@@ -644,6 +678,7 @@ 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,
|
||||
@@ -677,6 +712,7 @@ class ContextService:
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
to_name,
|
||||
content,
|
||||
category,
|
||||
entity_id,
|
||||
@@ -686,7 +722,7 @@ class ContextService:
|
||||
FROM entity_graph
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
relation_type, to_name, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
|
||||
@@ -256,6 +256,41 @@ 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}")
|
||||
|
||||
|
||||
@@ -600,6 +600,62 @@ 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:
|
||||
|
||||
@@ -89,6 +89,27 @@ 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."""
|
||||
|
||||
@@ -109,8 +130,10 @@ 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)
|
||||
|
||||
runner.invoke(cli_app, ["mcp"]) # default transport is stdio
|
||||
result = runner.invoke(cli_app, ["mcp"]) # default transport is stdio
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Command should not have set these vars
|
||||
assert env_at_run["FORCE_LOCAL"] is None
|
||||
@@ -132,8 +155,10 @@ 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)
|
||||
|
||||
runner.invoke(cli_app, ["mcp"])
|
||||
result = runner.invoke(cli_app, ["mcp"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Externally-set vars should be preserved
|
||||
assert env_at_run["FORCE_CLOUD"] == "true"
|
||||
@@ -153,7 +178,8 @@ class TestMcpCommandRouting:
|
||||
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
||||
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
||||
|
||||
runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
|
||||
result = runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert env_at_run["FORCE_LOCAL"] == "true"
|
||||
assert env_at_run["EXPLICIT"] == "true"
|
||||
@@ -172,7 +198,8 @@ class TestMcpCommandRouting:
|
||||
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
||||
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
||||
|
||||
runner.invoke(cli_app, ["mcp", "--transport", "sse"])
|
||||
result = runner.invoke(cli_app, ["mcp", "--transport", "sse"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert env_at_run["FORCE_LOCAL"] == "true"
|
||||
assert env_at_run["EXPLICIT"] == "true"
|
||||
|
||||
@@ -591,36 +591,35 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_accepts_workspace_in_local_mode(
|
||||
async def test_create_project_workspace_without_credentials_fails_fast(
|
||||
mcp_server, app, test_project, tmp_path
|
||||
):
|
||||
"""Passing workspace via the MCP wire is accepted by the tool schema and
|
||||
does not break the local create path.
|
||||
"""A workspace selector without cloud credentials fails fast — no local create (#954).
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
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
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
# The failed create must not leave a local project behind
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert "ws-local-test" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
assert "ws-local-test" not in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -54,6 +55,46 @@ 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
|
||||
@@ -137,6 +178,37 @@ 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."""
|
||||
@@ -373,6 +445,41 @@ 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."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import StringIO
|
||||
from typing import Any, cast
|
||||
@@ -15,6 +16,7 @@ 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,
|
||||
@@ -159,6 +161,19 @@ 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)
|
||||
|
||||
@@ -219,8 +219,8 @@ def test_setup_does_not_partially_write_generated_files_when_target_exists(
|
||||
mock_seed.assert_not_awaited()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.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.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.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.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.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.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.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.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
|
||||
def test_publish_command_uses_project_id_without_workspace_qualifying_project(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
|
||||
@@ -86,6 +86,46 @@ 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 = (
|
||||
|
||||
@@ -87,7 +87,7 @@ def _mock_config_manager():
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_diff",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_diff",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_diff",
|
||||
"basic_memory.mcp.tools.schema_diff",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"error": "No schema found for type 'person'"},
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ SEARCH_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_write_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_write_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_write_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_write_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_write_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_read_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_read_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_delete_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_edit_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_edit_note",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_build_context",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_build_context",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_recent_activity",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_recent_activity",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_recent_activity",
|
||||
"basic_memory.mcp.tools.recent_activity",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
)
|
||||
@@ -586,7 +586,7 @@ def test_recent_activity_empty(mock_mcp_recent):
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
"basic_memory.mcp.tools.search_notes",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
@@ -606,7 +606,7 @@ def test_search_notes_json_output(mock_mcp_search):
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
"basic_memory.mcp.tools.search_notes",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
@@ -622,7 +622,7 @@ def test_search_notes_with_meta_filter(mock_mcp_search):
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
"basic_memory.mcp.tools.search_notes",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
@@ -638,7 +638,7 @@ def test_search_notes_permalink_mode(mock_mcp_search):
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
"basic_memory.mcp.tools.search_notes",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Error: search failed",
|
||||
)
|
||||
@@ -699,7 +699,7 @@ SCHEMA_VALIDATE_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"error": "No notes found of type 'person'"},
|
||||
)
|
||||
@@ -769,7 +769,7 @@ SCHEMA_INFER_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.schema_infer",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_INFER_RESULT,
|
||||
)
|
||||
@@ -818,7 +818,7 @@ SCHEMA_DIFF_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_diff",
|
||||
"basic_memory.mcp.tools.schema_diff",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_DIFF_RESULT,
|
||||
)
|
||||
@@ -860,7 +860,7 @@ LIST_PROJECTS_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_list_projects",
|
||||
"basic_memory.mcp.tools.list_memory_projects",
|
||||
new_callable=AsyncMock,
|
||||
return_value=LIST_PROJECTS_RESULT,
|
||||
)
|
||||
@@ -898,7 +898,7 @@ LIST_WORKSPACES_RESULT = {
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_list_workspaces",
|
||||
"basic_memory.mcp.tools.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.cli.commands.tool.mcp_list_workspaces",
|
||||
"basic_memory.mcp.tools.list_workspaces",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"workspaces": [], "count": 0},
|
||||
)
|
||||
|
||||
@@ -195,15 +195,21 @@ 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"]
|
||||
|
||||
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
|
||||
# _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.db,
|
||||
"get_or_create_db",
|
||||
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.db.get_or_create_db",
|
||||
AsyncMock(return_value=(None, session_maker)),
|
||||
)
|
||||
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("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, "Progress", SilentProgress)
|
||||
monkeypatch.setattr(
|
||||
db_cmd.console,
|
||||
@@ -277,14 +283,17 @@ async def test_reindex_embeddings_only_full_passes_force_full_to_vector_reindex(
|
||||
if "total" in kwargs:
|
||||
self.tasks[task_id].total = kwargs["total"]
|
||||
|
||||
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
|
||||
# _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.db,
|
||||
"get_or_create_db",
|
||||
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.db.get_or_create_db",
|
||||
AsyncMock(return_value=(None, session_maker)),
|
||||
)
|
||||
monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock())
|
||||
monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository)
|
||||
monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock())
|
||||
monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository)
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.repository.search_repository.create_search_repository",
|
||||
lambda *args, **kwargs: object(),
|
||||
|
||||
@@ -368,7 +368,7 @@ def test_status_wait_json_timeout_emits_error(
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_validate",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_infer",
|
||||
"basic_memory.mcp.tools.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.cli.commands.schema.mcp_schema_diff",
|
||||
"basic_memory.mcp.tools.schema_diff",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DIFF_REPORT_WITH_DRIFT,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -27,7 +27,6 @@ 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 = {
|
||||
@@ -47,7 +46,7 @@ class TestKnowledgeClient:
|
||||
assert kwargs.get("params") is None
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -57,7 +56,6 @@ 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 = {
|
||||
@@ -77,7 +75,7 @@ class TestKnowledgeClient:
|
||||
assert kwargs.get("params") is None
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_put", mock_call_put)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_put", mock_call_put)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -87,7 +85,6 @@ 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 = {
|
||||
@@ -107,7 +104,7 @@ class TestKnowledgeClient:
|
||||
assert kwargs.get("params") is None
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_patch", mock_call_patch)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_patch", mock_call_patch)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -117,7 +114,6 @@ 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"}
|
||||
@@ -126,7 +122,7 @@ class TestKnowledgeClient:
|
||||
assert "/v2/projects/proj-123/knowledge/resolve" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -136,7 +132,6 @@ 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 = {
|
||||
@@ -156,7 +151,7 @@ class TestKnowledgeClient:
|
||||
assert kwargs.get("json") == {"file_path": "notes/disk-note.md"}
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -166,7 +161,6 @@ 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()
|
||||
@@ -186,7 +180,7 @@ class TestKnowledgeClient:
|
||||
assert "/v2/projects/proj-123/knowledge/orphans" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
@@ -211,7 +205,6 @@ 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 = {
|
||||
@@ -225,7 +218,7 @@ class TestSearchClient:
|
||||
assert kwargs.get("params") == {"page": 1, "page_size": 10}
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(search_mod, "call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = SearchClient(mock_http, "proj-123")
|
||||
@@ -248,7 +241,6 @@ 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()
|
||||
@@ -264,7 +256,7 @@ class TestMemoryClient:
|
||||
assert "/v2/projects/proj-123/memory/specs/search" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
@@ -274,7 +266,6 @@ 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()
|
||||
@@ -293,7 +284,7 @@ class TestMemoryClient:
|
||||
assert params.get("depth") == 2
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
@@ -304,7 +295,6 @@ 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()
|
||||
@@ -322,7 +312,7 @@ class TestMemoryClient:
|
||||
assert params.get("type") == "note,spec"
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
@@ -344,7 +334,6 @@ 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"}]
|
||||
@@ -353,7 +342,7 @@ class TestDirectoryClient:
|
||||
assert "/v2/projects/proj-123/directory/list" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(directory_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = DirectoryClient(mock_http, "proj-123")
|
||||
@@ -376,7 +365,6 @@ 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"
|
||||
@@ -385,7 +373,7 @@ class TestResourceClient:
|
||||
assert "/v2/projects/proj-123/resource/entity-123" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(resource_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = ResourceClient(mock_http, "proj-123")
|
||||
@@ -405,7 +393,6 @@ 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 = {
|
||||
@@ -425,7 +412,7 @@ class TestProjectClient:
|
||||
assert "/v2/projects" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(project_mod, "call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = ProjectClient(mock_http)
|
||||
|
||||
@@ -618,3 +618,42 @@ 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
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestSchemaClientValidate:
|
||||
assert kwargs.get("params") == {}
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_post", mock_call_post)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.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.clients.schema.call_get", mock_call_get)
|
||||
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
|
||||
|
||||
result = await schema_client.diff("person")
|
||||
assert len(result.new_fields) == 1
|
||||
|
||||
@@ -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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -694,6 +694,170 @@ 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
|
||||
@@ -731,7 +895,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -862,7 +1026,7 @@ async def test_detect_project_from_identifier_prefix_resolves_workspace_with_loc
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_index(context=None):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -917,7 +1081,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -957,7 +1121,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -998,7 +1162,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -1040,7 +1204,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -1154,7 +1318,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -1210,7 +1374,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -1247,7 +1411,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
|
||||
@@ -1502,7 +1666,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):
|
||||
async def fake_index(context=None, force_refresh=False):
|
||||
return index
|
||||
|
||||
get_client_calls: list[dict[str, str | None]] = []
|
||||
@@ -2989,3 +3153,142 @@ 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"
|
||||
|
||||
@@ -254,3 +254,53 @@ 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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
|
||||
|
||||
|
||||
@@ -7,8 +8,10 @@ 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():
|
||||
@@ -16,5 +19,7 @@ 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
|
||||
|
||||
@@ -6,7 +6,10 @@ 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]] = {
|
||||
@@ -106,6 +109,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"content",
|
||||
"directory",
|
||||
"project",
|
||||
"workspace",
|
||||
"project_id",
|
||||
"tags",
|
||||
"note_type",
|
||||
@@ -157,3 +161,16 @@ 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"
|
||||
|
||||
@@ -2115,3 +2115,161 @@ 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"]
|
||||
|
||||
@@ -7,10 +7,104 @@ 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.
|
||||
@@ -476,6 +570,39 @@ 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.
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""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)
|
||||
@@ -291,9 +291,11 @@ 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
|
||||
target_exists = await entity_repository.find_by_id(target.id)
|
||||
assert target_exists is not None
|
||||
# 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -134,6 +134,115 @@ 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."""
|
||||
@@ -228,6 +337,34 @@ 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."""
|
||||
|
||||
@@ -809,3 +809,182 @@ async def test_sync_entity_vectors_batch_logs_resolved_fastembed_runtime_setting
|
||||
assert runtime_logs[0]["threads"] == 4
|
||||
assert runtime_logs[0]["configured_parallel"] == 2
|
||||
assert runtime_logs[0]["effective_parallel"] == 2
|
||||
|
||||
|
||||
# --- Entity-aware ranking boost (#951) ---
|
||||
|
||||
|
||||
def _make_index_row(
|
||||
*,
|
||||
row_id: int,
|
||||
title: str,
|
||||
row_type: str = SearchItemType.ENTITY.value,
|
||||
) -> SearchIndexRow:
|
||||
"""Build a real SearchIndexRow for entity-boost matching tests."""
|
||||
now = datetime(2026, 1, 1)
|
||||
return SearchIndexRow(
|
||||
project_id=1,
|
||||
id=row_id,
|
||||
type=row_type,
|
||||
file_path=f"notes/{row_id}.md",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
permalink=f"notes/{row_id}",
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractQueryEntityTerms:
|
||||
"""Verify proper-noun extraction from query strings."""
|
||||
|
||||
def test_extracts_single_proper_noun(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("What are Joanna's hobbies?")
|
||||
assert terms == {"joanna"}
|
||||
|
||||
def test_extracts_multiple_proper_nouns(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms(
|
||||
"What symbolic gifts do Deborah and Jolene have from their mothers?"
|
||||
)
|
||||
assert terms == {"deborah", "jolene"}
|
||||
|
||||
def test_who_is_anthony(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("Who is Anthony?")
|
||||
assert terms == {"anthony"}
|
||||
|
||||
def test_all_lowercase_query_has_no_entity_terms(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("what is the weather today")
|
||||
assert terms == set()
|
||||
|
||||
def test_capitalized_stopword_at_sentence_start_is_ignored(self):
|
||||
# "What" and "Who" are capitalized interrogatives, not entity names.
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("What does Sarah like?")
|
||||
assert terms == {"sarah"}
|
||||
|
||||
def test_all_caps_token_is_extracted(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("who works at NASA")
|
||||
assert terms == {"nasa"}
|
||||
|
||||
def test_possessive_is_stripped(self):
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("Joanna's mother")
|
||||
assert terms == {"joanna"}
|
||||
|
||||
def test_single_capital_letter_is_ignored(self):
|
||||
# "A" lowercases to a stopword; "X" is a non-stopword single letter that
|
||||
# still carries no entity signal and must be dropped by the length guard.
|
||||
terms = SearchRepositoryBase._extract_query_entity_terms("A is for X and Apple")
|
||||
assert terms == {"apple"}
|
||||
|
||||
def test_empty_and_none_inputs(self):
|
||||
assert SearchRepositoryBase._extract_query_entity_terms("") == set()
|
||||
assert SearchRepositoryBase._extract_query_entity_terms(None) == set()
|
||||
|
||||
|
||||
class TestRowEntityMatchCount:
|
||||
"""Verify lexical match counting between query terms and row entity names."""
|
||||
|
||||
def test_title_match_counts_one(self):
|
||||
row = _make_index_row(row_id=1, title="Joanna's Hobbies")
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, {"joanna"}) == 1
|
||||
|
||||
def test_relation_title_matches_both_endpoints(self):
|
||||
row = _make_index_row(
|
||||
row_id=2,
|
||||
title="Deborah -> Jolene",
|
||||
row_type=SearchItemType.RELATION.value,
|
||||
)
|
||||
count = SearchRepositoryBase._row_entity_match_count(row, {"deborah", "jolene"})
|
||||
assert count == 2
|
||||
|
||||
def test_no_match_returns_zero(self):
|
||||
row = _make_index_row(row_id=3, title="Anthony Profile")
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, {"joanna"}) == 0
|
||||
|
||||
def test_match_is_case_insensitive(self):
|
||||
row = _make_index_row(row_id=4, title="JOANNA notes")
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, {"joanna"}) == 1
|
||||
|
||||
def test_empty_terms_returns_zero(self):
|
||||
row = _make_index_row(row_id=5, title="Joanna")
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, set()) == 0
|
||||
|
||||
def test_missing_title_returns_zero(self):
|
||||
row = _make_index_row(row_id=6, title="")
|
||||
row.title = None
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, {"joanna"}) == 0
|
||||
|
||||
def test_distinct_terms_not_double_counted(self):
|
||||
# A title containing the same term twice still counts as one distinct match.
|
||||
row = _make_index_row(row_id=7, title="Joanna and Joanna")
|
||||
assert SearchRepositoryBase._row_entity_match_count(row, {"joanna"}) == 1
|
||||
|
||||
|
||||
class TestApplyEntityBoost:
|
||||
"""Verify the entity-boost score math and gating."""
|
||||
|
||||
def _repo(self, *, enabled: bool, weight: float = 0.15, max_terms: int = 3) -> _ConcreteRepo:
|
||||
repo = _ConcreteRepo()
|
||||
repo._entity_boost_enabled = enabled
|
||||
repo._entity_boost_weight = weight
|
||||
repo._entity_boost_max_terms = max_terms
|
||||
return repo
|
||||
|
||||
def test_disabled_returns_scores_unchanged(self):
|
||||
repo = self._repo(enabled=False)
|
||||
key = ("entity", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Joanna")}
|
||||
scores = {key: 0.5}
|
||||
assert repo._apply_entity_boost(scores, rows, {"joanna"}) == {key: 0.5}
|
||||
|
||||
def test_matching_row_is_boosted(self):
|
||||
repo = self._repo(enabled=True, weight=0.2)
|
||||
key = ("entity", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Joanna")}
|
||||
boosted = repo._apply_entity_boost({key: 0.5}, rows, {"joanna"})
|
||||
assert boosted[key] == pytest.approx(0.5 * 1.2)
|
||||
|
||||
def test_non_matching_row_unchanged(self):
|
||||
repo = self._repo(enabled=True, weight=0.2)
|
||||
key = ("entity", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Anthony")}
|
||||
boosted = repo._apply_entity_boost({key: 0.5}, rows, {"joanna"})
|
||||
assert boosted[key] == pytest.approx(0.5)
|
||||
|
||||
def test_boost_can_reorder_lower_scored_match_above_higher_non_match(self):
|
||||
repo = self._repo(enabled=True, weight=0.5)
|
||||
generic_key = ("entity", 1)
|
||||
joanna_key = ("entity", 2)
|
||||
rows = {
|
||||
generic_key: _make_index_row(row_id=1, title="Generic topic about hobbies"),
|
||||
joanna_key: _make_index_row(row_id=2, title="Joanna"),
|
||||
}
|
||||
# The generic row starts higher (0.6) but does not match; "Joanna" (0.5) matches.
|
||||
boosted = repo._apply_entity_boost({generic_key: 0.6, joanna_key: 0.5}, rows, {"joanna"})
|
||||
assert boosted[joanna_key] > boosted[generic_key]
|
||||
|
||||
def test_multiple_matches_scale_boost(self):
|
||||
repo = self._repo(enabled=True, weight=0.1, max_terms=3)
|
||||
key = ("relation", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Deborah -> Jolene")}
|
||||
boosted = repo._apply_entity_boost({key: 1.0}, rows, {"deborah", "jolene"})
|
||||
# Two matched terms -> 1 + 0.1 * 2 = 1.2
|
||||
assert boosted[key] == pytest.approx(1.2)
|
||||
|
||||
def test_max_terms_caps_the_boost(self):
|
||||
repo = self._repo(enabled=True, weight=0.1, max_terms=1)
|
||||
key = ("relation", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Deborah -> Jolene")}
|
||||
boosted = repo._apply_entity_boost({key: 1.0}, rows, {"deborah", "jolene"})
|
||||
# Capped at 1 term -> 1 + 0.1 * 1 = 1.1
|
||||
assert boosted[key] == pytest.approx(1.1)
|
||||
|
||||
def test_zero_weight_is_noop(self):
|
||||
repo = self._repo(enabled=True, weight=0.0)
|
||||
key = ("entity", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Joanna")}
|
||||
assert repo._apply_entity_boost({key: 0.5}, rows, {"joanna"}) == {key: 0.5}
|
||||
|
||||
def test_empty_entity_terms_is_noop(self):
|
||||
repo = self._repo(enabled=True, weight=0.2)
|
||||
key = ("entity", 1)
|
||||
rows = {key: _make_index_row(row_id=1, title="Joanna")}
|
||||
assert repo._apply_entity_boost({key: 0.5}, rows, set()) == {key: 0.5}
|
||||
|
||||
@@ -76,6 +76,32 @@ def _entity_row(
|
||||
)
|
||||
|
||||
|
||||
def _relation_row(
|
||||
*,
|
||||
project_id: int,
|
||||
row_id: int,
|
||||
entity_id: int,
|
||||
title: str,
|
||||
permalink: str,
|
||||
relation_type: str,
|
||||
) -> SearchIndexRow:
|
||||
now = datetime.now(timezone.utc)
|
||||
return SearchIndexRow(
|
||||
project_id=project_id,
|
||||
id=row_id,
|
||||
type=SearchItemType.RELATION.value,
|
||||
title=title,
|
||||
permalink=permalink,
|
||||
file_path=f"{permalink}.md",
|
||||
metadata=None,
|
||||
entity_id=entity_id,
|
||||
from_id=entity_id,
|
||||
relation_type=relation_type,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _enable_semantic(
|
||||
search_repository: SQLiteSearchRepository,
|
||||
embedding_provider: StubEmbeddingProvider | None = None,
|
||||
@@ -498,6 +524,71 @@ async def test_sqlite_vector_search_returns_ranked_entities(search_repository):
|
||||
assert all(result.type == SearchItemType.ENTITY.value for result in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_vector_search_survives_cross_type_id_collision(search_repository):
|
||||
"""Entity and relation rows sharing one numeric id must both hydrate (#982).
|
||||
|
||||
Entity, observation, and relation rows carry ids from independent
|
||||
auto-increment sequences, so search_index rows of different types routinely
|
||||
share the same numeric id. Keying vector hydration by bare id collapsed
|
||||
colliding hits into one dict slot and silently dropped the other result.
|
||||
"""
|
||||
if not isinstance(search_repository, SQLiteSearchRepository):
|
||||
pytest.skip("sqlite-vec repository behavior is local SQLite-only.")
|
||||
|
||||
_enable_semantic(search_repository)
|
||||
await search_repository.init_search_index()
|
||||
await search_repository.bulk_index_items(
|
||||
[
|
||||
_entity_row(
|
||||
project_id=search_repository.project_id,
|
||||
row_id=7,
|
||||
entity_id=701,
|
||||
title="Auth Token Design",
|
||||
permalink="specs/auth-token-design",
|
||||
content_stems="auth token session login design",
|
||||
),
|
||||
# Same numeric id as the entity row above, different row type.
|
||||
_relation_row(
|
||||
project_id=search_repository.project_id,
|
||||
row_id=7,
|
||||
entity_id=702,
|
||||
title="login flow relates to auth token design",
|
||||
permalink="specs/login-flow/relates-to/auth-token-design",
|
||||
relation_type="relates_to",
|
||||
),
|
||||
]
|
||||
)
|
||||
await search_repository.sync_entity_vectors(701)
|
||||
await search_repository.sync_entity_vectors(702)
|
||||
|
||||
results = await search_repository.search(
|
||||
search_text="session token auth",
|
||||
retrieval_mode=SearchRetrievalMode.VECTOR,
|
||||
limit=5,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Both rows match the query; both share id=7 and must survive hydration.
|
||||
assert len(results) == 2
|
||||
assert {result.type for result in results} == {
|
||||
SearchItemType.ENTITY.value,
|
||||
SearchItemType.RELATION.value,
|
||||
}
|
||||
entity_result = next(r for r in results if r.type == SearchItemType.ENTITY.value)
|
||||
assert entity_result.permalink == "specs/auth-token-design"
|
||||
|
||||
# The type filter must keep the entity even though a relation shares its id.
|
||||
filtered = await search_repository.search(
|
||||
search_text="session token auth",
|
||||
search_item_types=[SearchItemType.ENTITY],
|
||||
retrieval_mode=SearchRetrievalMode.VECTOR,
|
||||
limit=5,
|
||||
offset=0,
|
||||
)
|
||||
assert [r.permalink for r in filtered] == ["specs/auth-token-design"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_hybrid_search_combines_fts_and_vector(search_repository):
|
||||
"""Hybrid mode fuses FTS and vector results with score-based fusion."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user