Compare commits

..

7 Commits

Author SHA1 Message Date
phernandez 2c30922e65 docs(core): add Basic Machines agent style guidance
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 15:37:43 -05:00
phernandez e8cda55ddb test(cli): make copyto path assertions Windows-portable
The Windows SQLite unit job failed because two new copyto tests asserted on
raw POSIX dest strings. On Windows `str(Path("/tmp/research"))` renders with
backslashes, so the copyto dest is `\tmp\research/notes/...` (rclone accepts the
mixed separators — the product is fine). Compare the local dest via `Path(...)`
like the other tests, which normalizes separators cross-platform.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 13:00:48 -05:00
phernandez c850450ddf fix(cli): address push/pull review feedback (fatal-error guard, UX, tests)
From the Codex and claude-review automated reviews on PR #917:

- project_diff: fail fast on fatal `rclone check` errors. A non-zero exit with
  no combined listing (auth/network/missing-remote) previously produced an empty
  plan, so the transfer ran as a no-op and reported success. Now raises
  RcloneError with rclone's stderr. (Codex P2 / claude-review #2)
- sync-setup "Next steps": stop pointing every user at the now-Personal-only
  `bm cloud sync`; lead with the Team-safe `pull`/`push` and note bisync is
  Personal-only. (claude-review #1)
- Cosmetic: capitalize the push/pull abort headlines for consistency; document
  the two ConflictStrategy definitions (Typer enum vs engine Literal). (nits)
- Tests: cover keep-local+pull and keep-cloud+push (preserve-destination cases),
  project_transfer aborting on a mid-loop conflict-copy failure, and project_diff
  raising on a fatal check error vs not raising on a normal differences exit.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:30:20 -05:00
phernandez ad1f8621f1 docs(cli): document cloud push/pull and Personal-only sync/bisync
Update docs/cloud-cli.md for the docs site:
- Add a Team Workspaces push/pull section (additive, git-style transfers,
  the --on-conflict {fail|keep-local|keep-cloud|keep-both} resolution, and the
  no-baseline limitations tracked by #862).
- Mark `sync` and `bisync` as Personal-workspace-only mirrors and point Team
  users at push/pull, with a Personal-vs-Team overview table.
- Fix stale command namespace: `bm project sync|bisync|check|bisync-reset` ->
  `bm cloud ...` (these live under `bm cloud`, not `bm project`).
- Add push/pull to the command reference and the summary workflows.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:25:22 -05:00
phernandez a7937d4066 fix(cli): compare by checksum on overwrite push/pull to match conflict detection
Second code-review finding (PLAUSIBLE): project_diff detects conflicts with
`rclone check` (content/hash), but project_copy used `rclone copy` with its
default size+modtime comparison. On an overwrite strategy (keep-cloud on pull /
keep-local on push), copy could skip a file the diff had flagged as a conflict
when sizes matched and the destination was not older — silently ignoring the
user's explicit choice.

Add `--checksum` to the overwrite copy so the transfer decision uses the same
content basis as detection. New-only mode is unaffected: `--ignore-existing`
skips by existence, so the comparison basis is irrelevant there. project_sync
(the Personal-only mirror) is untouched and keeps its default comparison.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:48:20 -05:00
phernandez 9dbe313193 fix(cli): add --local-no-preallocate to conflict-copy copyto
Code-review finding: project_copy_file built its `rclone copyto` command
inline and omitted `--local-no-preallocate`. On a `pull --on-conflict keep-both`
that copyto writes the conflict copy to the local filesystem, which is exactly
the case the flag guards (NUL byte padding on virtual filesystems such as
Google Drive File Stream). Every other transfer path adds it via
_build_transfer_cmd; bring copyto in line.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:44:20 -05:00
phernandez e23363606a feat(cli): add Team-safe cloud push/pull and gate sync to Personal workspaces
Adds `bm cloud push` and `bm cloud pull` as git-style, fail-safe transfer
primitives that are usable on Team workspaces (issue #858), and restricts the
destructive `bm cloud sync` mirror to Personal workspaces.

Why
- `bm cloud sync` is a destructive local->cloud mirror; on a shared Team bucket
  it can delete a teammate's files. The only pull path was two-way `bisync`,
  which is already Personal-only (#849).
- Teams need a safe way to fetch teammates' notes and add their own without one
  stale local tree becoming authoritative for shared cloud state.

What
- push = `rclone copy` local->cloud, pull = `rclone copy` cloud->local. Both are
  additive (never delete on the destination), so neither can damage shared state.
- Conflicts (a file that differs on both sides) abort by default and list the
  paths, like git refusing to clobber local changes / rejecting a stale push.
  `--on-conflict {fail|keep-local|keep-cloud|keep-both}` lets the user decide;
  no vague --force, no silent winner.
- `sync` now requires a Personal workspace; its guard and the bisync guard point
  Team users at push/pull.

Limitations (surfaced in --help and command output, tracked by #862):
- No sync baseline yet, so deletions are not propagated and every divergence is
  treated as a conflict rather than auto-resolved. Real three-way merge needs
  the per-client manifest + Tigris snapshot baseline designed in #862.

Implementation
- rclone_commands.py: shared `_build_transfer_cmd`/`_transfer_endpoints`;
  refactor `project_sync` onto them (no behavior change); add `project_diff`
  (conflict detection via `rclone check --combined`), `project_copy`,
  `project_copy_file`, and `project_transfer` (strategy dispatch).
- project_sync.py: new `push`/`pull` commands (ungated, Team-safe) with
  `--on-conflict`/`--dry-run`; gate `sync` to Personal via a per-command guard
  message.
- Tests at the rclone-argv and CLI-command levels; existing sync/bisync tests
  updated for the new gating and messages.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:35:13 -05:00
172 changed files with 1513 additions and 7946 deletions
@@ -1,5 +1,5 @@
---
name: code-review
name: basic-machines-review
description: Use when reviewing Basic Machines code for house style, architecture risk, pre-merge hardening, or whether a change fits basic-memory/basic-memory-cloud conventions.
license: MIT
---
@@ -18,9 +18,9 @@ Review the current diff or named files against:
- `docs/ENGINEERING_STYLE.md`
- The touched code paths and tests
Apply only the guidance for the active repo. In `basic-memory`, prioritize local-first
file/database/MCP boundaries. In `basic-memory-cloud`, prioritize tenant/workspace isolation,
cloud worker behavior, and web-v2 state/runtime boundaries.
For `basic-memory`, prioritize local-first file/database/MCP boundaries. For
`basic-memory-cloud`, prioritize tenant/workspace isolation, cloud worker behavior, and
web-v2 state/runtime boundaries.
## Review Rubric
@@ -53,12 +53,6 @@ Report only concrete, falsifiable risks:
Lead with findings ordered by severity. Each finding should include:
| Severity | Use for |
| -------- | ------- |
| `high` | A likely correctness, security, data-loss, or tenant/workspace isolation failure |
| `medium` | A concrete maintainability or boundary risk that can cause future defects |
| `low` | A minor consistency issue, ambiguous guidance, or review-only cleanup |
```text
severity | file:line | risk category | claim
Why: concrete behavior or code path that proves the risk.
-48
View File
@@ -1,48 +0,0 @@
---
name: fix-pr-issues
description: Use when addressing Basic Memory pull request feedback, failed checks, or BM Bossbot blockers from Codex.
---
# Fix Basic Memory PR Issues
Resolve PR feedback and failed checks, then wait for BM Bossbot to approve the
new head SHA. This skill never merges a PR.
## Gather
1. Identify the PR:
- `gh pr view --json number,url,headRefOid,mergeStateStatus,statusCheckRollup`
2. Collect feedback:
- PR comments and review summaries
- inline review comments and unresolved review threads
- failed GitHub Actions jobs and relevant logs
- the managed `BM_BOSSBOT_SUMMARY` block in the PR body
3. Build a short issue ledger:
- source
- concrete problem
- expected fix
- verification needed
## Fix
1. Address one ledger item at a time.
2. Read each file in full before editing it.
3. Keep diffs narrow and preserve unrelated user changes.
4. Run the smallest meaningful verification first, then widen as needed.
5. Commit with `git commit -s` when code or docs changed.
## Push And Recheck
1. Push the branch.
2. Watch checks for the new `headRefOid`.
3. Wait for the required `BM Bossbot Approval` status to pass on that exact SHA.
4. If BM Bossbot reviews an older SHA, treat the approval as stale and keep
waiting for the current one.
## Reply
For each addressed comment or blocker, reply with the fix commit, verification
run, and current BM Bossbot status. Do not resolve or dismiss substantive
feedback without evidence.
@@ -1,7 +0,0 @@
interface:
display_name: "Fix PR Issues"
short_description: "Address PR feedback and BM Bossbot blockers"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $fix-pr-issues to address PR feedback and wait for BM Bossbot Approval on the latest head SHA."
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

Before

Width:  |  Height:  |  Size: 249 B

-247
View File
@@ -1,247 +0,0 @@
---
name: infographics
description: Use when generating Basic Memory PR, changelog, release, or weekly images from Codex.
---
# Basic Memory Images
Generate repository visuals with evidence-grounded content and canonical output
paths. The file and marker names still say "infographic" for compatibility, but
PR generation is image-first: scene, poster, painting, photograph, cover,
tableau, staged artifact, or another editorial visual moment that describes the
intent of the PR. PR images are non-gating BM Bossbot artifacts; changelog and
release-summary images are manual evidence-pack workflows.
## Output Contract
- Base output directory: `docs/assets/infographics/`
- PR image: `docs/assets/infographics/pr-<number>.webp`
- Changelog image: `docs/assets/infographics/changelog.webp`
- Weekly image:
- This is always a 2-Week Retro window: previous ISO week through current ISO
week (`start-week = current-week - 1`, `end-week = current-week`).
- Same year window: `docs/assets/infographics/<year>-w<start-week>-w<end-week>.webp`
- Cross-year window:
`docs/assets/infographics/<start-year>-w<start-week>-<end-year>-w<end-week>.webp`
## PR Mode
PR mode uses the BM Bossbot summary block as source material. Do not hand-write
claims that are not present in the PR body.
1. Fetch the PR body:
```bash
gh pr view <number> --json body --jq '.body // ""' > /tmp/bm-pr-body.md
```
2. Generate the canonical asset:
```bash
uv run --script scripts/generate_pr_infographic.py \
--pr-number <number> \
--pr-body-file /tmp/bm-pr-body.md \
--theme "<optional visual theme>" \
--provenance-output /tmp/bm-infographic-provenance.md \
--output docs/assets/infographics/pr-<number>.webp
```
If the PR body contains a managed image theme block, the script reads it
automatically:
```markdown
<!-- BM_INFOGRAPHIC_THEME:start -->
<theme>
<!-- BM_INFOGRAPHIC_THEME:end -->
```
Before spending an image call, test the prompt path locally:
```bash
uv run --script scripts/generate_pr_infographic.py \
--pr-number <number> \
--pr-body-file /tmp/bm-pr-body.md \
--theme "<optional visual theme>" \
--output docs/assets/infographics/pr-<number>.webp \
--print-prompt
```
`--dry-run` is an alias for `--print-prompt`; both print the final prompt and
exit without calling OpenAI.
When no theme is supplied, the script selects a deterministic BM visual
direction from the style pool below based on the PR number and Bossbot summary.
This keeps repeated PR images from collapsing into the same generic visual.
When the image is generated, also write provenance with
`--provenance-output <path>`. BM Bossbot publishes that managed block into the
PR body with these markers:
```markdown
<!-- BM_INFOGRAPHIC_PROVENANCE:start -->
...
<!-- BM_INFOGRAPHIC_PROVENANCE:end -->
```
The provenance block records the generated asset path, image model, size,
quality, image mode, theme source, and selected visual direction. It
intentionally does not dump the full generated prompt into the PR body. Treat
this block as debugging and creative provenance only; it is not a merge gate.
The PR image is visual support only. The authoritative merge gate is the
GitHub commit status named `BM Bossbot Approval`.
## Changelog Mode
Build an evidence pack before writing a prompt:
- diff truth source: merged PR diffs, merge commits, or local reconstructed diffs
- changed-file orientation: `git diff --stat` plus key file reads
- impact ledger: before/after outcomes tied to actual changes
- discard list: misleading titles, reverted work, rename-only churn, speculative TODOs
- chosen image form: poster, scene, tableau, cover, painting, photograph,
staged artifact, or another editorial visual moment
- chosen BM style category: exactly one category from the selection pool below
Read these references before drafting the prompt:
- `references/prompt-blueprint.md`
- `references/style-balance.md`
Read the current `CHANGELOG.md` entries and include the latest meaningful
changes.
## Style And Category Selection
Select exactly one BM style category per image based on semantic fit. The
visual language should be recognizable and tasteful, while staying
business-readable.
Create an image-first visual form that communicates the change: poster, scene,
tableau, cover image, painting, photograph, staged artifact, or another
editorial visual moment. Maps, diagrams, dossiers, charts, and labels can appear
as props inside the scene, but do not make a text-heavy infographic.
BM category pool:
- computer science college textbooks: SICP-style diagrams, algorithms lectures,
compiler pipelines, automata, database systems, type theory, operating systems
- classic literature subjects: sea voyages, gothic manors, Dickensian city maps,
Austen social graphs, library marginalia, travel journals
- fantasy/D&D-inspired: quest maps, dungeon keys, guild ledgers, spellbooks,
bestiaries, tavern notice boards; no copyrighted settings
- Music: Metal, Hard Rock, Punk, techno, soul, reggae bands; no pop music, no
direct band logos, album covers, or musician likenesses
- sci-fi: Star Wars inspired knockoff, Spaceballs-adjacent space opera, fleet
routes, mission consoles, contraband manifests; avoid copyrighted characters,
logos, or named fictional universes
- Conan the barbarian-inspired sword-and-sorcery: ruined temples, desert routes,
battle standards, ancient maps; no named character likenesses
- Comic books: issue covers, splash pages, action-panel maps, caption boxes,
halftone energy, clean sound-effect typography
- French new wave movies: poster style, stark typography, city route maps,
jump-cut sequencing, high-contrast editorial photography cues
- WWII propaganda posters: home-front public-information poster language,
logistics arrows, ration charts, mobilization maps, bold simplified figures;
no real-world party symbols, hate imagery, dehumanizing slogans, or false
historical claims
- Italian movie posters: hand-painted drama, bold credits, expressive color,
route-map collage, 1960s or 1970s cinema energy; no direct film titles or
actor likenesses
- Shakespeare: stage maps, acts and scenes, dramatis personae, royal courts,
backstage cue sheets
- Greek mythology: temple diagrams, constellation routes, hero's journey maps,
oracle tablets, labyrinths, ship routes
- noir detective boards: case files, red-string maps, typed evidence labels,
precinct wall charts
- NASA mission-control dashboards: launch timelines, telemetry maps, orbital
routes, status boards
- space exploration and astronomy: celestial atlases, observatory charts,
star-field maps, orbital mechanics diagrams, planetary survey routes,
telescope annotations, mission trajectories, deep-space timelines
- paintings: abstract painting, classical landscape, Remington-inspired western
action painting, Rembrandt-inspired chiaroscuro, historical mural, stormy
seascape, allegorical editorial painting
- classic black-and-white photography: documentary field report, newsroom
archive print, editorial photo essay, street photography, high-contrast
darkroom print, contact sheet, civic infrastructure photograph
- 80's action movies: practical explosions, smoky backlit warehouses, neon city
streets, helicopter searchlights, mission dossiers, heroic silhouettes,
high-stakes countdowns, painted ensemble posters; no direct actor likenesses,
real film titles, franchise marks, or catchphrases
- alchemy manuscripts: transformation diagrams, annotated symbols, recipe-like
process maps, illuminated margins
- brutalist civic planning: transit maps, concrete signage, zoning blocks,
infrastructure diagrams
Selection rules:
- Pick one category only; do not create mixed mashups.
- Pick the most appropriate image form. Prefer an actual scene, poster,
painting, photograph, tableau, or cover over a text-heavy infographic.
- Match metaphor to content, but do not overthink it. The category is a creative
catalyst, not a semantic constraint.
- Use a polished editorial rendering direction: smooth anti-aliased
text, high contrast, clean edges, readable labels.
- Make the category drive the composition through a readable staged moment,
editorial composition, symbolic environment, route, artifact, or visual
metaphor.
- Keep the structure literal enough to aid understanding, but not so heavy that
it obscures engineering meaning.
- Give the image generator creative latitude on layout, structure, color palette,
and visual metaphors. Be precise about what content to show, loose about how
to show it.
- Do not use copyrighted characters, logos, or named fictional universes. Use
genre cues, knockoffs, and original compositions instead.
## Content-First Aesthetic Contract
The meaning must be readable and clearly hierarchical. Everything else is
creative territory: image form, layout, visual metaphors, decorative elements,
color choices, and category-specific visual language.
Hierarchy:
1. Meaning: what shipped, what changed, and why it matters must be clear.
2. If the image uses text, labels, sections, or evidence bullets, they must be
legible.
3. The selected category's visual DNA should drive the composition as a poster,
scene, painting, photograph, tableau, cover, or symbolic object arrangement.
4. Do not play it safe. A visually striking image that someone wants to look at
beats a correct but boring one.
Hard rules:
- Content sections and labels must be readable when present. Text cannot be
obscured by decorations.
- Do not use lore-heavy copy that competes with engineering or business meaning.
- Every prompt must include a clear image-first composition cue: a staged scene,
poster composition, painting, photograph, symbolic tableau, hero object,
mission room, dossier, artifact, route, or visual metaphor.
- Do not over-prescribe exact coordinates or panel geometry; give a composition
backbone and let the model compose around it.
## Generation
1. Write the final prompt to a temporary markdown file.
2. Generate with the shared image helper:
```bash
uv run --script scripts/generate_infographic.py \
--prompt-file /tmp/bm-infographic-prompt.md \
--output docs/assets/infographics/<name>.webp
```
3. Verify the image exists and is readable before reporting success.
## Quality Bar
- Tell a concrete before/after value story, not vague improvement claims.
- Stay understandable for both engineers and non-technical stakeholders.
- Use plain-language section titles and labels when text is present.
- Include clear visual hierarchy: title, staged focal point, symbolic scene,
evidence props, or hero object.
- Avoid invented facts; only use provided source material.
- Favor shipped outcomes over intermediate or reverted work.
- Preserve readability with high contrast, non-tiny labels, and uncluttered
layout.
@@ -1,7 +0,0 @@
interface:
display_name: "Infographics"
short_description: "Generate Basic Memory repo infographics"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $infographics to generate a Basic Memory PR or changelog infographic with canonical output paths."
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

Before

Width:  |  Height:  |  Size: 249 B

@@ -1,72 +0,0 @@
# Prompt Blueprint
Convert an evidence pack into a final visual prompt. Be precise about the
content and loose about visual execution.
## Required Inputs
- Diff truth source summary
- Changed-file orientation summary
- Impact ledger with before/after outcomes
- Discard list for excluded noise
- Chosen image form
- Chosen BM style category
## Prompt Shape
```text
Create a polished Basic Memory editorial image inspired by
<BM_STYLE_CATEGORY>. Use a poster, scene, tableau, painting, photograph, cover
image, staged artifact, or another image-first form that best communicates the
intent. Use HD editorial rendering with smooth anti-aliased text when text is
present. Go bold and let the selected category drive the visual language through
original, non-infringing cues.
TITLE:
- "<clear title>"
- "<scope subtitle>"
COMPOSITION:
- Recreate a clear staged moment or symbolic image that describes the PR
intent.
- Maps, diagrams, dossiers, route lines, labels, and artifacts can appear as
props inside the scene, but the output should read as an image rather than a
dense infographic.
- Take creative liberty with layout and styling.
- The hard rule: the meaning must be readable and clearly hierarchical.
- Keep labels plain-language and technical when labels are used.
CONTENT:
1. "<section>"
- <evidence-grounded outcome>
- <evidence-grounded outcome>
2. "<section>"
- <evidence-grounded outcome>
- <evidence-grounded outcome>
METRICS:
- <metric>
- <metric>
STYLE DIRECTION:
- Upscaled editorial, high contrast, anti-aliased text, smooth edges.
- Let the category's visual DNA drive the composition.
- Use genre/category cues only; do not use copyrighted characters, logos, named
fictional universes, direct band logos, album art, or celebrity likenesses.
DO NOT:
- Make text unreadable or let decoration obscure content.
- Render a text-heavy infographic, dashboard, flowchart, timeline strip,
checklist, bullet-list panel, or dense explanatory diagram.
- Use crunchy low-resolution pixel art.
- Invent facts not present in the evidence pack.
```
## Writing Rules
- Keep each bullet specific and evidence-grounded.
- Prefer outcome language over implementation trivia.
- Default to three or four sections; never exceed five.
- Give proportionally more space to dominant changes.
- Keep the final prompt short, energetic, and readable.
@@ -1,42 +0,0 @@
# Style Balance Rubric
## Core Principle
Be bold, not confusing. The selected BM style category should structure the
visual through a readable image-first composition, not decorate a generic grid.
Use an editorial scene, poster, painting, photograph, cover, staged artifact, or
tableau that turns the PR intent into a visual moment.
## Required Traits
- Anti-aliased typography
- Smooth edges
- High contrast between text and background
- Plain-language section labels
- Clear composition backbone: staged scene, editorial poster, painting,
photograph, symbolic tableau, hero artifact, dossier, mission room, or route
embodied as part of the scene
- A single coherent BM style category, expressed through original visual cues
## Reject Or Rewrite If
- Content text is unreadable.
- The prompt lacks a composition backbone.
- The prompt over-prescribes exact panel positions or a rigid grid.
- The style leans into crunchy low-resolution pixelation.
- Copy uses lore-heavy references instead of engineering meaning.
- The prompt uses copyrighted characters, logos, named fictional universes,
direct band logos, album art, or celebrity likenesses.
## Creative Integration Patterns
- Use category-native map details to organize content: textbook diagrams,
literary journeys, quest maps, tour posters, mission-control routes, stage
blocking, mythic constellations, star charts, mission trajectories, case
boards, or civic plans as props inside the image.
- Recreate a scene, editorial poster, painting, photograph, cover, artifact, or
tableau instead of sectioned bullets.
- Map engineering metrics to visual counters, route progress, or status boards
only when they naturally belong in the scene.
- Let headers and accents borrow from the selected style.
- Keep atmospheric details behind or around content, never over it.
-114
View File
@@ -1,114 +0,0 @@
---
name: pr-create
description: Use when creating or updating a Basic Memory pull request from Codex with BM Bossbot merge-gate monitoring.
---
# Create A Basic Memory PR
Create or update a pull request for the current branch, then wait for BM
Bossbot to approve the latest head SHA. This skill never merges a PR.
## Inputs
- Optional `<theme>`: free-form visual direction for the non-gating PR
image. Example: `$pr-create "Italian movie poster"`.
- Treat `<theme>` as style guidance only. It must not affect PR readiness,
BM Bossbot review, status checks, or merge behavior.
## How To Use
Ask Codex to use the skill from a feature branch:
```text
$pr-create
$pr-create "Italian movie poster"
$pr-create "80's action movies"
```
Use the plain form when you only want the PR workflow. Pass a theme when you
want the non-gating image to lean toward a particular visual direction. The
theme can be specific ("Rembrandt-inspired approval scene") or broad ("let the
model choose from BM categories").
## What Happens
1. Codex checks the branch, local verification, GitHub auth, commit sign-offs,
and semantic PR title shape.
2. Codex pushes the branch, creates or reuses the PR, and adds the optional
`BM_INFOGRAPHIC_THEME` block when a theme was supplied.
3. BM Bossbot runs from trusted base code, reviews sanitized PR metadata and
diff context, and sets the required `BM Bossbot Approval` status for the
exact head SHA.
4. If approval succeeds, BM Bossbot may publish a non-gating image block and a
provenance block:
```markdown
<!-- BM_INFOGRAPHIC_PROVENANCE:start -->
...
<!-- BM_INFOGRAPHIC_PROVENANCE:end -->
```
The provenance records the image mode, theme source, selected visual
direction, and image settings. It is for review/debugging context only.
5. Codex reports the PR URL, head SHA, checks watched, verification run, and BM
Bossbot verdict.
The skill never merges, never enables auto-merge, and never treats the image or
provenance block as a gate. The only required merge signal is the
`BM Bossbot Approval` status on the current PR head SHA.
## Preflight
1. Confirm the repo and branch:
- `git status --short --branch`
- stop if detached or on `main`
- keep unrelated user changes intact
2. Confirm GitHub access:
- `gh auth status`
- `gh repo view --json nameWithOwner,defaultBranchRef,url`
3. Check PR readiness:
- commits are signed off with `git commit -s`
- title uses the repo semantic format
- local verification appropriate to the change has run
## Create Or Reuse
1. Push the branch:
- `git push -u origin HEAD`
2. Check for an existing PR:
- `gh pr view --json number,url,headRefOid,mergeStateStatus,statusCheckRollup`
3. If no PR exists, create one:
- `gh pr create --fill`
- adjust the title if it does not satisfy the semantic PR title workflow
4. If `<theme>` is provided, add or update this managed block in the PR body:
```markdown
<!-- BM_INFOGRAPHIC_THEME:start -->
<theme>
<!-- BM_INFOGRAPHIC_THEME:end -->
```
Keep the rest of the PR body intact. The theme is non-gating image guidance
only.
5. Do not merge. Do not enable auto-merge.
## Watch The Gate
1. Trigger or wait for `.github/workflows/bm-bossbot.yml`.
2. Watch the required commit status named `BM Bossbot Approval`.
3. Treat approval as valid only when it is green for the current `headRefOid`.
4. If the branch changes after approval, wait for BM Bossbot to review the new
head SHA.
5. If BM Bossbot fails or requests changes, use `$fix-pr-issues`.
## Report
Return the PR URL, current head SHA, checks watched, verification run, and the
BM Bossbot verdict. Include the image `<theme>` if one was supplied. Be
explicit when any check is still pending.
@@ -1,7 +0,0 @@
interface:
display_name: "PR Create"
short_description: "Create PRs and wait for BM Bossbot"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $pr-create to create or update this Basic Memory PR and wait for BM Bossbot Approval."
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

Before

Width:  |  Height:  |  Size: 249 B

+2 -2
View File
@@ -6,14 +6,14 @@
},
"metadata": {
"description": "Official Basic Memory plugins from the canonical basic-memory repository",
"version": "0.22.1"
"version": "0.21.6"
},
"plugins": [
{
"name": "basic-memory",
"source": "./plugins/claude-code",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph \u2014 session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.22.1",
"version": "0.21.6",
"author": {
"name": "Basic Machines"
},
+23 -35
View File
@@ -28,10 +28,7 @@ You are an expert release manager for the Basic Memory project. When the user ru
#### Documentation Validation
1. **Changelog Check**
- CHANGELOG.md contains entry for target version **already landed on `main`**
(main only accepts changes via PR, so the changelog entry must go through
its own PR before running the release; the recipe pre-flight-checks for a
`## vX.Y.Z` heading)
- CHANGELOG.md contains entry for target version
- Entry includes all major features and fixes
- Breaking changes are documented
@@ -44,15 +41,11 @@ just release <version>
The justfile target handles:
- ✅ Version format validation
- ✅ Git status and branch checks
-Changelog entry check (must already be on `main`)
- ✅ Quality checks (`just lint` + `just typecheck`)
-Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
-Release PR: commits the bump on a `release/vX.Y.Z` branch, opens a PR
(`chore(core): release vX.Y.Z`), and rebase-merges it — the `main` ruleset
rejects direct pushes and the repo disallows merge commits
- ✅ Tags the rebased bump commit on `main` (found by commit subject, since
the rebase rewrites the SHA) and pushes the tag
-Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Release workflow trigger (automatic on tag push)
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
@@ -96,7 +89,7 @@ After PyPI release is published, update the MCP registry:
2. **Publish to MCP Registry**
```bash
# from the basic-memory repo root
cd /Users/drew/code/basic-memory
mcp-publisher publish
```
@@ -116,7 +109,7 @@ After PyPI release is published, update the MCP registry:
#### Website Updates
**1. basicmachines.co** (sibling `basicmachines.co` repo)
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
- **Goal**: Update version number displayed on the homepage
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
- **What to update**:
@@ -133,31 +126,26 @@ After PyPI release is published, update the MCP registry:
7. Push branch: `git push origin release/v{VERSION}`
- **Deploy**: Follow deployment process for basicmachines.co
**2. docs.basicmemory.com** (sibling `docs.basicmemory.com` repo)
- **Goal**: Add a What's New page for the release and bump the homepage badge
- **Site shape**: Nuxt/Docus content site. The changelog page
(`content/2.whats-new/*.changelog.md`) auto-fetches GitHub releases — no
manual changelog update needed. See that repo's CLAUDE.md "Version Bump
Checklist".
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
- **Goal**: Add new release notes section to the latest-releases page
- **File**: `src/pages/latest-releases.mdx`
- **What to do**:
1. Pull latest from GitHub: `git pull origin main`
2. Create release branch: `git checkout -b release/v{VERSION}`
3. Read `CHANGELOG.md` in the `basic-memory` repo to get release content
4. **New minor/major release**: add `content/2.whats-new/1.v{VERSION}.md`
modeled on the previous version page (frontmatter title/description,
headline feature first, then sections, then an Upgrading note) and
renumber the existing what's-new pages down one slot (URLs don't
change — Nuxt strips the numeric prefixes)
5. **Patch release**: append a short note to the current version's page
instead of creating a new one
6. Update the homepage version badge in `content/index.md` (the
`v0.XX →` button text and its `to: /whats-new/v{VERSION}` link)
7. If the release adds user-facing features, update the relevant guide
and reference pages (`content/3.cloud/`, `content/9.reference/`)
8. Commit: `git commit -s -m "docs: add v{VERSION} release notes"`
9. Push branch and open a PR; merge after the release is tagged
- **Deploy**: push to main auto-deploys to development; production requires
manual workflow dispatch via GitHub Actions
3. Read the existing file to understand the format and structure
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
5. Add new release section **at the top** (after MDX imports, before other releases)
6. Follow the existing pattern:
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
- Focus statement if applicable
- `<Info>` block with highlights (3-5 key items)
- Sections for Features, Bug Fixes, Breaking Changes, etc.
- Link to full changelog at the end
- Separator `---` between releases
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
8. Push branch: `git push origin release/v{VERSION}`
- **Source content**: Extract and format sections from CHANGELOG.md for this version
- **Deploy**: Follow deployment process for docs.basicmemory.com
**4. Announce Release**
- Post to Discord community if significant changes
+16 -15
View File
@@ -1,18 +1,26 @@
name: Claude Code Review
"on":
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to review manually
required: true
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude-review:
if: inputs.pr_number != ''
# Only run for organization members and collaborators
if: |
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -35,14 +43,7 @@ jobs:
track_progress: true # Enable visual progress tracking
allowed_bots: '*'
prompt: |
Review Basic Memory PR #${{ inputs.pr_number }} as an advisory manual review.
Use `gh pr view ${{ inputs.pr_number }}` and related `gh pr`/`gh api`
commands to inspect the pull request. Do not merge the PR and do not
treat this advisory review as the required merge gate. BM Bossbot owns
the required `BM Bossbot Approval` status.
Review the PR against our team checklist:
Review this Basic Memory PR against our team checklist:
## Code Quality & Standards
- [ ] Follows Basic Memory's coding conventions in CLAUDE.md
+8 -6
View File
@@ -13,10 +13,9 @@ env:
jobs:
docker:
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
steps:
@@ -25,8 +24,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up Depot
uses: depot/setup-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
platforms: linux/amd64,linux/arm64
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
@@ -48,12 +49,13 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: depot/build-push-action@v1
uses: docker/build-push-action@v7
with:
project: ${{ vars.DEPOT_BASIC_MEMORY_PROJECT_ID || vars.DEPOT_PROJECT_ID }}
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+10 -123
View File
@@ -13,43 +13,9 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Branch builds (PRs arrive as push events — this workflow has no
# pull_request trigger) select only impacted tests from the cached testmon
# baseline (branch cache falling back to main's full-run recording). Pushes
# to main run the full suite with --testmon-noselect to refresh the baseline.
BASIC_MEMORY_TESTMON_FLAGS: ${{ github.ref_name == 'main' && '--testmon-noselect' || '--testmon --testmon-forceselect' }}
jobs:
changes:
# Docs/workflow-only changes skip the entire test matrix while the workflow
# still concludes successfully, so the BM Bossbot gate (workflow_run on
# Tests success) keeps firing and the PR stays mergeable.
name: Detect code changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
- id: filter
uses: dorny/paths-filter@v3
with:
# Tests only runs on push events; for branch pushes compare against
# main (merge-base), for main pushes dorny diffs the push range.
base: main
filters: |
code:
- 'src/**'
- 'tests/**'
- 'test-int/**'
- 'alembic/**'
- 'pyproject.toml'
- 'uv.lock'
- 'justfile'
- '.github/workflows/test.yml'
static-checks:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Static Checks (Python 3.12)
timeout-minutes: 20
runs-on: ubuntu-latest
@@ -88,8 +54,6 @@ jobs:
just lint
test-sqlite-unit:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
strategy:
@@ -100,8 +64,6 @@ jobs:
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
# Python 3.14 unit tests are the longest full-suite slice; keep this
# one on GitHub-hosted runners after Depot terminated it mid-suite.
- os: ubuntu-latest
python-version: "3.14"
- os: windows-latest
@@ -125,19 +87,6 @@ jobs:
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
uv venv
@@ -151,8 +100,6 @@ jobs:
just test-unit-sqlite
test-sqlite-integration:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
strategy:
@@ -186,19 +133,6 @@ jobs:
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
uv venv
@@ -212,19 +146,15 @@ jobs:
just test-int-sqlite
test-postgres-unit:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Postgres Unit (Python ${{ matrix.python-version }}, shard ${{ matrix.group }}/3)
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# Shard the largest suite across parallel jobs: each shard is a full job
# with its own Postgres service running 1/3 of the collection.
# Postgres runs on the latest Python only — the SQLite matrix carries
# Python-version coverage; Postgres carries backend coverage.
group: [1, 2, 3]
python-version: ["3.14"]
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest
services:
postgres:
@@ -260,20 +190,6 @@ jobs:
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-main-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
uv venv
@@ -284,19 +200,18 @@ jobs:
- name: Run tests
run: |
BASIC_MEMORY_PYTEST_SPLIT_FLAGS="--splits 3 --group ${{ matrix.group }}" just test-unit-postgres
just test-unit-postgres
test-postgres-integration:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Postgres Integration (Python ${{ matrix.python-version }})
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# Latest Python only: SQLite carries version coverage, Postgres carries
# backend coverage.
python-version: ["3.14"]
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest
services:
postgres:
@@ -332,19 +247,6 @@ jobs:
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
uv venv
@@ -358,8 +260,6 @@ jobs:
just test-int-postgres
test-semantic:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Semantic (Python 3.12)
timeout-minutes: 45
runs-on: ubuntu-latest
@@ -381,19 +281,6 @@ jobs:
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}-
${{ runner.os }}-testmon-semantic-py3.12-main-
${{ runner.os }}-testmon-semantic-py3.12-
- name: Create virtual env
run: |
uv venv
+1 -1
View File
@@ -49,7 +49,7 @@ ENV/
/docs/.obsidian/
/examples/.obsidian/
/examples/.basic-memory/
/docs/assets
# claude action
claude-output
+6 -8
View File
@@ -133,9 +133,9 @@ short version for agents:
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
- **Minimize diffs**: Prefer the smallest change that satisfies the request. Avoid unrelated refactors or style rewrites unless necessary for correctness
- **House style is canonical**: Follow the Programming Style section above for type-safe,
fail-fast code; do not hide unclear models with speculative attributes, broad exception
handling, casts, or unapproved fallback logic
- **No speculative getattr**: Never use `getattr(obj, "attr", default)` when unsure about attribute names. Check the class definition or source code first
- **Fail fast**: Write code with fail-fast logic by default. Do not swallow exceptions with errors or warnings
- **No fallback logic**: Do not add fallback logic unless explicitly told to and agreed with the user
- **No guessing**: Do not say "The issue is..." before you actually know what the issue is. Investigate first.
### Literate Programming Style
@@ -300,9 +300,7 @@ See SPEC-16 for full context manager refactor details.
### Release Process
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, land the bump on `main` through a release PR, tag, and push the tag. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
**Main requires PRs.** The `main` ruleset rejects direct pushes ("Changes must be made through a pull request") and the repo disallows merge commits, so the recipes push a `release/vX.Y.Z` branch, open a PR titled `chore(core): release vX.Y.Z`, rebase-merge it with `gh pr merge --rebase`, then tag the rebased bump commit on `main` (located by its commit subject, since rebasing rewrites the SHA) and push the tag. The CHANGELOG entry for the version must already be on `main` — land it via a normal PR before running the recipe (it pre-flight-checks for a `## vX.Y.Z` heading).
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
**Stable release:**
@@ -310,7 +308,7 @@ Releases are driven by `just release` / `just beta` — never by a bare `git tag
just release v0.21.3
```
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It commits as `chore: update version to X.Y.Z for vX.Y.Z release` on a `release/vX.Y.Z` branch, lands it on `main` via a rebase-merged PR, then tags the rebased commit and pushes the tag. After the tag lands, the `Release` workflow builds the Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
@@ -321,7 +319,7 @@ The recipe runs `just lint` + `just typecheck`, then updates every release manif
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
**Post-release tasks** the recipe surfaces but doesn't run:
- `docs.basicmemory.com` — add a What's New page under `content/2.whats-new/` and bump the version badge in `content/index.md` (the changelog page auto-fetches GitHub releases; see that repo's CLAUDE.md version-bump checklist)
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
- MCP Registry — `mcp-publisher publish` from the repo root
-109
View File
@@ -1,114 +1,5 @@
# CHANGELOG
## v0.22.1 (2026-06-12)
Follow-up patch to v0.22.0. Fixes project and default-project resolution on
fresh installs, MCP workspace routing, sync project selection, and CLI startup
latency, plus a few MCP parity additions.
### Features
- Added a `workspace` parameter to `write_note` for parity with `edit_note`.
- **#826**: Added `title` and `tags` annotations to all MCP tool decorators
(phase 1).
- **#930**: `search_notes` now comma-splits `note_types`, `entity_types`, and
`categories`.
- **#971**: Added the manual-pages flow — manpage seed schema, flow docs, and
verification fixes.
### Bug Fixes
- Fresh installs no longer fail when the projects table is empty: resolve now
points them at project setup, the first project is promoted to default when
the config default is missing from the database, the promoted default state
is returned from the project-create API, and a default can be set when none
is currently set. An existing database default is preserved when repairing a
missing config default.
- **#949**: Sync skips projects without an absolute local path and excludes
orphan DB projects that are absent from config.
- **#952 / #981**: Resolved workspace display names and tenant ids in qualified
project routes, closing out the manual verification findings.
- `note_types`/`entity_types`/`categories` are normalized on the direct-call
path, with non-string list elements rejected.
- Vector-search hydration keys on `(type, id)` to prevent id collisions.
- `file_utils` requires line-anchored frontmatter fences.
- CLI startup is faster: FastAPI and app imports are deferred out of CLI
startup, and rich/typer modules are preloaded before an in-place upgrade.
- `config.json` is written atomically.
- In-memory SQLite sessions are serialized so concurrent rollbacks cannot
destroy writes.
### Maintenance
- Release recipes route through PRs and wait for the release PR merge to land
before tagging; the release runbook is refreshed and stripped of
user-specific absolute paths.
## v0.22.0 (2026-06-11)
Team-safe cloud sync. New additive `bm cloud push` and `bm cloud pull`
commands work safely on shared Team workspaces, while the destructive mirror
commands are gated to Personal workspaces. Also: a large batch of MCP tool
fixes, search improvements, and embedding reliability work.
### Features
- **#917**: Added Team-safe `bm cloud push` / `bm cloud pull`. Both are
additive (they never delete on the destination) and abort on conflicts by
default, git-style, with `--on-conflict {fail|keep-local|keep-cloud|keep-both}`.
The destructive `bm cloud sync` / `bm cloud bisync` mirrors are now gated
to Personal workspaces.
- **#920**: Team push/pull uses per-workspace rclone remotes, so remotes and
credentials stay scoped to each workspace.
- **#908**: Search supports an observation category filter.
- **#809**: Added an experimental LiteLLM embedding provider for semantic
search (marked experimental, see **#899**).
- **#907**: `bm tool write-note` accepts `--type`.
- **#906**: `bm status` accepts `--wait` and `--timeout`.
- Added the `bm tool delete-note` command.
- **#905**: Improved workspace and cloud bisync command discoverability.
### Bug Fixes
- **#931 / #946**: Truncated observation permalinks are disambiguated to
prevent search-index collisions, and `build_context` resolves observations
by the same permalink the search index uses (**#909**, **#929**).
- **#934**: `edit_note` recovers when the file exists on disk but is not yet
indexed (**#581**).
- **#911 / #932 / #941**: Comma-separated tags are split consistently in
`parse_tags` and the `search_notes` tags parameter, with input normalized
for direct callers (**#910**).
- **#933**: `read_note` accepts `page`/`page_size` for parity with sibling
tools (**#883**).
- **#914 / #904 / #916**: `move_note` resolves `memory://` URLs, stops
falsely rejecting same-project moves as cross-project, no longer reports
false success across project boundaries, and mismatch guidance points at
the landing path.
- **#915**: Navigation pagination is validated, and `recent_activity` shows
the correct project.
- **#913**: `bm tool` commands align with MCP behavior (error exit codes,
overwrite handling, category support, defaults).
- **#923**: `bm cloud setup` no longer overwrites an existing rclone remote
(**#922**).
- **#912**: The `note_types` search filter is case-insensitive.
- `build_context` allows cross-project context traversal, `write_note`
resolves overwrite conflicts, and sync uses strict deferred relation
resolution.
- Embedding reliability: FastEmbed vectors are L2-normalized (**#843**),
corrupt FastEmbed model caches self-heal (**#900**), a single embedding
provider is reused per process (**#903**), `sqlite-vec` loads for the
embedding-status query (**#901**), and engine disposal no longer crashes
on the Postgres backend (**#902**).
### Maintenance
- Leaner, faster CI: testmon-selected branch builds, sharded Postgres jobs,
and faster default test fixtures (**#928**, **#938**, **#945**).
- Documented personal-vs-team cloud sync semantics (**#947**, closes
**#851**).
- Fixed npx skill install docs (**#927**).
- Added `glama.json` to claim the Glama MCP directory listing (**#953**).
## v0.21.6 (2026-06-04)
Monorepo consolidation plus a redesigned Claude Code plugin. The satellite
+5 -5
View File
@@ -216,14 +216,14 @@ Source: [`plugins/claude-code`](plugins/claude-code).
### Shared skills
Framework-agnostic `SKILL.md` files live in [`skills/`](skills). If your
Skills CLI supports repository subdirectory sources:
Skills CLI supports subpath installs:
```bash
npx skills add basicmachines-co/basic-memory/skills
npx skills add basicmachines-co/basic-memory --path skills
```
If your installed Skills CLI cannot load that source, update the CLI or copy
the `memory-*` directories from `skills/` into your agent's skills directory.
If it does not, copy the `memory-*` directories from `skills/` into your
agent's skills directory as a temporary Phase 1 install path.
### Hermes
@@ -590,7 +590,7 @@ retention).
| `BASIC_MEMORY_IMPORT_UPLOAD_MAX_BYTES` | `104857600` | Max uploaded import size |
```bash
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory reindex
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
tail -f ~/.basic-memory/basic-memory.log
```
+3 -3
View File
@@ -111,7 +111,7 @@ You can run Basic Memory CLI commands inside the container using `docker exec`:
docker exec basic-memory-server basic-memory status
# Sync files
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
# Show help
docker exec basic-memory-server basic-memory --help
@@ -137,7 +137,7 @@ When using Docker volumes, you'll need to configure projects to point to your mo
3. **Sync the new project:**
```bash
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
```
### Example: Setting up an Obsidian Vault
@@ -157,7 +157,7 @@ docker exec basic-memory-server basic-memory project create obsidian /app/data
docker exec basic-memory-server basic-memory project set-default obsidian
# Sync to index all files
docker exec basic-memory-server basic-memory reindex
docker exec basic-memory-server basic-memory sync
```
### Environment Variables
+2 -2
View File
@@ -184,8 +184,8 @@ finance/ (lowercase f)
Use Basic Memory's built-in conflict detection:
```bash
# Index local file changes (conflicts are handled during the scan)
basic-memory reindex
# Sync will report conflicts
basic-memory sync
# Check sync status for warnings
basic-memory status
+3 -5
View File
@@ -318,7 +318,7 @@ For MCP stdio, routing is always local.
| `bm cloud push` | local → cloud | Personal + Team | Upload local changes, additively (git-style) |
| `bm cloud sync` | local → cloud | Personal only | One-way mirror (cloud becomes identical to local) |
| `bm cloud bisync` | local ↔ cloud | Personal only | Two-way mirror (recommended for solo use) |
| `bm cloud check` | — | Personal only | Verify mirror integrity (no changes) |
| `bm cloud check` | — | Personal + Team | Verify files match (no changes) |
If you collaborate on a shared Team workspace, use **`push`/`pull`** (see [Team Workspaces](#team-workspaces-push--pull-additive-git-style)). If you are the only writer (a Personal workspace), the mirror commands `sync`/`bisync` give you a single source of truth.
@@ -459,12 +459,10 @@ bm cloud bisync --name research
- You edit in multiple places
- You want automatic conflict resolution
### Verify Sync Integrity (Personal only)
### Verify Sync Integrity
**Use case:** Check if local and cloud match without making changes.
> **Personal workspaces only.** `check` compares against the Personal workspace mirror remote, like `sync`/`bisync`. On Team workspaces use `bm cloud pull --dry-run` / `bm cloud push --dry-run` to preview differences instead.
```bash
bm cloud check --name research
```
@@ -1001,7 +999,7 @@ bm cloud bisync --name <project> --resync # First time / force baseline
bm cloud bisync --name <project> --dry-run
bm cloud bisync --name <project> --verbose
# Integrity check - Personal workspaces only
# Integrity check
bm cloud check --name <project>
bm cloud check --name <project> --one-way
-183
View File
@@ -1,183 +0,0 @@
# Manual Pages
Basic Memory's manual is written in the style of Unix man pages — and
implemented as Basic Memory notes ([#952](https://github.com/basicmachines-co/basic-memory/issues/952)).
Every page is a markdown note conforming to the `Manpage` schema, `SEE ALSO`
entries are real knowledge-graph relations, and every example on every page
was executed against a live project before the page shipped. The manual
documents the tools; the tools verify the manual.
## Where it lives
The canonical manual is the **`manual` project in the Basic Memory team
workspace** (cloud, shared). Anyone can build their own: the schema ships as
an opt-in seed at `plugins/claude-code/schemas/manpage.md` — copy it into any
project's folder and start writing pages against it.
Layout:
```
manual/
├── schemas/Manpage.md # the manpage schema (type: schema)
├── man1/ # CLI commands bm(1), bm-status(1), ...
├── man3/ # MCP tools write-note(3), search-notes(3), ...
├── man5/ # file formats bm-note(5), bm-observation(5), ...
├── man7/ # concepts basic-memory(7), semantic-memory(7), ...
├── playground/ # scratch notes for destructive examples
└── diagrams/ # canvas visualizations of the manual graph
```
### Why "man1", "man3", "man5"?
The folder names are Unix's, unchanged since 1971. The manual is divided
into numbered **sections**, pages physically live in directories named
after them (`/usr/share/man/man1`, `man5`, ...), and the number tells you
what *kind* of thing is documented — not importance, not reading order:
- **1** — user commands (`ls`, `grep`)
- **2** — system calls
- **3** — library functions / APIs (`printf(3)`)
- **4** — devices
- **5** — file formats and config files (`crontab(5)`, `passwd(5)`)
- **6** — games (really)
- **7** — miscellanea: concepts, conventions, overviews (`regex(7)`, `signal(7)`)
- **8** — system administration
That's also why man page names carry the parenthesized number —
`crontab(1)` is the command, `crontab(5)` is the file format, same name in
two sections. `man 5 crontab` picks the section explicitly.
This manual copies that layout with the sections that have a Basic Memory
analog:
- **man1/** — `bm` CLI commands → `bm-status(1)`
- **man3/** — MCP tools, our equivalent of the "library API" section → `write-note(3)`
- **man5/** — file formats: note syntax, observations, relations, schemas → `bm-note(5)`
- **man7/** — concepts → `basic-memory(7)`, `semantic-memory(7)`
- **8** is reserved for admin/cloud operations but has no pages yet; 2, 4,
and 6 have no analog (no system calls, no devices, and no games — yet)
When a page says `see_also [[bm-note(5)]]`, the `(5)` reads "the
file-format page," exactly the way a Unix manual cross-references — except
here it's a traversable relation in the graph instead of a typographic
convention. The manual explains its own conventions in `man-pages(7)`
fittingly, the same page name Linux uses for this, and that almost nobody
ever reads.
## Page anatomy
Pages use the classic headers where applicable: `NAME`, `SYNOPSIS`,
`DESCRIPTION`, `PARAMETERS`, `MCP USAGE`, `CLI EQUIVALENT`, `EXAMPLES`,
`GOTCHAS`, `SEE ALSO`. Frontmatter (validated by the schema):
```yaml
type: manpage
section: 3 # 1 | 3 | 5 | 7 | 8
name: write-note # page name without section suffix
summary: create or overwrite a markdown note in the knowledge base
generated: hand # hand | registry | typer (regeneration ownership)
tool: write_note # section-3 pages: the MCP tool documented
command: basic-memory status # section-1 pages: the CLI command documented
verified: 0.21.6 mcp+cli # version + path(s) that proved the page
```
Field knowledge accumulates as observations — `[gotcha]`, `[bug]` (with issue
links), `[pattern]` — and `SEE ALSO` entries are `see_also` relations, so the
manual is a navigable graph, not a folder of files.
## How to use it
Man-style reads (any MCP client or the CLI):
```bash
# read a page
bm tool read-note "man3/write-note-3" --project manual
# apropos — find pages by section, tool, or text
bm tool search-notes --project manual # then filter, or via MCP:
# search_notes(project="manual", metadata_filters={"type": "manpage", "section": 3})
# search_notes(project="manual", metadata_filters={"type": "manpage", "tool": "write_note"})
# traverse SEE ALSO from any page
# build_context(url="man3/write-note-3", project="manual")
```
A future `bm man <topic>` command is thin sugar over exactly these calls.
And for the real thing — `man bm` in an actual terminal:
```bash
bm man install # copies bundled groff pages to ~/.local/share/man
man bm # the overview page, rendered by man(1)
man basic-memory # same page via its alias
```
`bm man install` warns with a one-line `MANPATH` fix if the install root
isn't searched by your `man`. Agents with shell access can use `man bm` as
an offline quick reference; the full per-tool detail stays in the manual
project's section-3 pages.
## The verification discipline
Two rules make the manual trustworthy:
1. **Examples must have run.** An `EXAMPLES` (or `MCP USAGE` / `CLI
EQUIVALENT`) block contains only commands that actually executed against
the manual project. Destructive operations (`delete_note`, `move_note`,
destructive `edit_note`) run only against `playground/` notes — never
against pages. The `verified:` field records the version and which path
proved the page: `mcp` (live service), `cli` (dev checkout), or both.
2. **The schema is the linter.** Validate the whole manual any time:
```bash
bm tool schema-validate manpage --project manual
# → {"total_notes": 38, "valid_count": 38, "warning_count": 0, ...}
```
`bm orphans --project manual` confirms every page is connected to the
graph, and `schema_diff`/`schema_infer` report drift between the schema
and how pages are actually written.
Because verification exercises real tool calls against the live service,
building the manual doubles as an end-to-end smoke test. The initial build
found six bugs in one pass (#954#959) — including the verification rule
catching a test that asserted a bug as expected output (#958).
## Adding or updating a page
1. Run the commands you intend to document; keep the actual output.
2. Write the page with `write_note`, passing frontmatter through the
`metadata` parameter (nested YAML in content frontmatter is unreliable on
some clients):
```
write_note(title="my-tool(3)", directory="man3", project="manual",
note_type="manpage",
metadata={"section": 3, "name": "my-tool",
"summary": "...", "generated": "hand",
"tool": "my_tool", "verified": "<version> mcp"})
```
3. Link related pages in `SEE ALSO` with `see_also [[other-page(3)]]`.
Forward references to pages that don't exist yet are fine — they resolve
automatically when the target is written.
4. Validate: `bm tool schema-validate manpage --project manual`.
For mechanical updates to generated sections, prefer `edit_note` with
`replace_section` / `insert_after_section` so curated content (EXAMPLES,
GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the
`generated:` field declares.
## Roadmap
- **Registry generator** — section-3 SYNOPSIS/PARAMETERS generated from the
MCP tool registry (docstrings + pydantic schemas), section-1 from Typer
help; the hand-written corpus is the template spec. Regenerate-and-diff in
CI becomes the drift gate.
- **`bm man <topic>`** — CLI sugar over `read_note` + metadata search.
(`bm man install` + a hand-written `bm.1` already ship — the first slice
of [#610](https://github.com/basicmachines-co/basic-memory/issues/610);
the generator will produce per-command pages from the same extraction.)
- **Docs site** — the notes remain canonical for sections 5 and 7, code is
canonical for 1 and 3; both render to the hosted docs site.
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": [
"phernandez",
"groksrc"
]
}
+1 -1
View File
@@ -38,7 +38,7 @@ from typing import Any, Callable
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
__version__ = "0.22.1"
__version__ = "0.21.6"
logger = logging.getLogger("hermes.memory.basic-memory")
+1 -1
View File
@@ -1,5 +1,5 @@
name: basic-memory
version: 0.22.1
version: 0.21.6
description: "Basic Memory — persistent knowledge graph backed by the basic-memory MCP server"
pip_dependencies:
- mcp
+1 -1
View File
@@ -150,7 +150,7 @@ This plugin ships with workflow-oriented skills that are automatically loaded wh
No manual installation needed. To update skills or install new ones as they become available:
```bash
npx skills add basicmachines-co/basic-memory/skills --agent openclaw
npx skills add basicmachines-co/basic-memory --path skills --agent openclaw
```
See the canonical source at [`basic-memory/skills`](../../skills).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@basicmemory/openclaw-basic-memory",
"version": "0.22.1",
"version": "0.21.6",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+48 -165
View File
@@ -1,12 +1,5 @@
# Basic Memory - Modern Command Runner
TESTMON_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_FLAGS", "--testmon-noselect")
TESTMON_SELECT_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_SELECT_FLAGS", "--testmon --testmon-forceselect")
TESTMON_REFRESH_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_REFRESH_FLAGS", "--testmon-noselect")
# CI shards the Postgres unit suite across parallel jobs via pytest-split
# (e.g. "--splits 3 --group 2"). Empty locally.
PYTEST_SPLIT_FLAGS := env_var_or_default("BASIC_MEMORY_PYTEST_SPLIT_FLAGS", "")
# Install dependencies
install:
uv sync
@@ -42,60 +35,40 @@ test-sqlite: test-unit-sqlite test-int-sqlite
test-postgres: test-unit-postgres test-int-postgres
# Run unit tests against SQLite
test-unit-sqlite: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-sqlite tests
test-unit-sqlite:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
# Run unit tests against Postgres
# Exit code 5 (no tests collected) is success: a testmon-selected PR build can
# leave a pytest-split shard empty.
test-unit-postgres: testmon-seed
#!/usr/bin/env bash
set -euo pipefail
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} {{PYTEST_SPLIT_FLAGS}} --testmon-env=unit-postgres tests || test $? -eq 5
test-unit-postgres:
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
# Run integration tests against SQLite (excludes semantic tests and on-demand benchmarks —
# use just test-semantic / run benchmark files explicitly)
test-int-sqlite: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-sqlite -m "not semantic and not benchmark" test-int
# Run integration tests against SQLite (excludes semantic benchmarks — use just test-semantic)
test-int-sqlite:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
# Run integration tests against Postgres
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
# See: https://github.com/jlowin/fastmcp/issues/1311
test-int-postgres: testmon-seed
test-int-postgres:
#!/usr/bin/env bash
set -euo pipefail
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
if [[ -n "$TIMEOUT_CMD" ]]; then
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-postgres -m "not semantic and not benchmark" test-int' || test $? -eq 137
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int' || test $? -eq 137
else
echo "⚠️ No timeout command found, running without timeout..."
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-postgres -m "not semantic and not benchmark" test-int
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
fi
# Run tests impacted by recent changes (requires pytest-testmon)
# Pass paths or node ids after `just testmon` to limit the candidate set further.
testmon *args: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_SELECT_FLAGS}} --testmon-env=local {{args}}
# Seed pytest-testmon data into this worktree from the shared Git cache.
testmon-seed:
uv run python scripts/testmon_cache.py seed
# Refresh the shared pytest-testmon cache from a full backend test run.
testmon-refresh:
#!/usr/bin/env bash
set -euo pipefail
BASIC_MEMORY_TESTMON_FLAGS="{{TESTMON_REFRESH_FLAGS}}" just test
uv run python scripts/testmon_cache.py refresh
# Show local and shared pytest-testmon cache locations.
testmon-status:
uv run python scripts/testmon_cache.py status
testmon *args:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon {{args}}
# Run MCP smoke test (fast end-to-end loop)
test-smoke: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=smoke -m smoke test-int/mcp/test_smoke_integration.py
test-smoke:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
# Fast local loop: lint, format, typecheck, impacted tests via pytest-testmon
fast-check:
@@ -124,18 +97,18 @@ postgres-migrate:
# Run Windows-specific tests only (only works on Windows platform)
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
# Will be skipped automatically on non-Windows platforms
test-windows: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=windows -m windows tests test-int
test-windows:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
# Run benchmark tests only (performance testing)
# These are slow tests that measure sync performance with various file counts
# Excluded from default test runs to keep CI fast
test-benchmark: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=benchmark -m benchmark tests test-int
test-benchmark:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
# Run semantic search quality benchmarks (all combos)
test-semantic: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=semantic -m semantic test-int/semantic/
test-semantic:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic test-int/semantic/
# Run semantic benchmarks with JSON artifact output, then show report
test-semantic-report:
@@ -147,8 +120,8 @@ test-litellm-live *args:
BASIC_MEMORY_ENV=test BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 PYTHONPATH=test-int:src uv run python -m semantic.litellm_live_harness {{args}}
# Run semantic benchmarks (Postgres combos only)
test-semantic-postgres: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=semantic-postgres -m semantic -k postgres test-int/semantic/
test-semantic-postgres:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic -k postgres test-int/semantic/
# View semantic benchmark results (rich formatted table)
# Usage: just semantic-report [--filter-combo sqlite] [--filter-suite paraphrase] [--sort-by avg_latency_ms]
@@ -164,8 +137,8 @@ benchmark-compare baseline candidate *args:
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
# Use this before releasing to ensure everything works across all backends and platforms
test-all: testmon-seed
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=all tests test-int
test-all:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int
# Generate HTML coverage report
coverage:
@@ -383,31 +356,17 @@ release version:
echo "❌ Tag {{version}} already exists"
exit 1
fi
# Changelog must already be on main (land it via a normal PR first)
if ! grep -q "^## {{version}} " CHANGELOG.md; then
echo "❌ CHANGELOG.md has no entry for {{version}}. Land one via PR first."
exit 1
fi
# Run quality checks
echo "🔍 Running lint checks..."
just lint
just typecheck
# Update all package manifests to the one Basic Memory product version.
echo "📝 Updating consolidated package versions..."
just set-version "{{version}}"
# Trigger: main's ruleset rejects direct pushes ("Changes must be made
# through a pull request").
# Why: the version bump must land on main before the tag is cut, so it
# rides a release PR that is rebase-merged (the repo disallows merge
# commits).
# Outcome: the bump commit gets a new SHA on main; the tag is created on
# that rebased commit, found by its commit subject.
COMMIT_SUBJECT="chore: update version to $VERSION_NUM for {{version}} release"
git checkout -b "release/{{version}}"
# Commit version update
git add \
src/basic_memory/__init__.py \
server.json \
@@ -418,56 +377,22 @@ release version:
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
git commit -s -m "$COMMIT_SUBJECT"
echo "📤 Opening release PR..."
git push -u origin "release/{{version}}"
gh pr create --title "chore(core): release {{version}}" \
--body "Version bump for {{version}}. See CHANGELOG.md for release notes."
# Trigger: the PR may not be mergeable synchronously (merge gates,
# required checks added later, or GitHub still computing mergeability).
# Why: the tag must point at the bump commit on main, so the recipe
# cannot tag until the merge has actually landed.
# Outcome: try a direct rebase-merge, fall back to queueing auto-merge,
# then poll main for the rebased bump commit before tagging.
if ! gh pr merge "release/{{version}}" --rebase --delete-branch; then
echo "⚠️ Direct merge did not complete (merge gates pending?). Queueing auto-merge..."
gh pr merge "release/{{version}}" --rebase --delete-branch --auto
fi
echo "⏳ Waiting for the bump commit to land on main..."
TAG_COMMIT=""
for _ in $(seq 1 60); do
git fetch origin main --quiet
TAG_COMMIT=$(git log FETCH_HEAD --fixed-strings --grep "$COMMIT_SUBJECT" --format='%H' -1)
[[ -n "$TAG_COMMIT" ]] && break
sleep 5
done
if [[ -z "$TAG_COMMIT" ]]; then
echo "❌ Bump commit not on main after 5 minutes (merge still pending?)."
echo " Once the release PR merges, finish the release manually:"
echo " git fetch origin main"
echo " git tag {{version}} \$(git log FETCH_HEAD --fixed-strings --grep \"$COMMIT_SUBJECT\" --format='%H' -1)"
echo " git push origin {{version}}"
exit 1
fi
git checkout main
git pull --ff-only origin main
git branch -D "release/{{version}}" 2>/dev/null || true
echo "🏷️ Creating tag {{version}} at $TAG_COMMIT..."
git tag "{{version}}" "$TAG_COMMIT"
git commit -s -m "chore: update version to $VERSION_NUM for {{version}} release"
# Create and push tag
echo "🏷️ Creating tag {{version}}..."
git tag "{{version}}"
echo "📤 Pushing to GitHub..."
git push origin main
git push origin "{{version}}"
echo "✅ Release {{version}} created successfully!"
echo "📦 GitHub Actions will build and publish to PyPI"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo ""
echo "📝 REMINDER: Post-release tasks:"
echo " 1. docs.basicmemory.com - Add a What's New page under content/2.whats-new/"
echo " and bump the badge in content/index.md (see that repo's CLAUDE.md)"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " 3. MCP Registry - Run: mcp-publisher publish"
echo " See: .claude/commands/release/release.md for detailed instructions"
@@ -515,15 +440,7 @@ beta version:
echo "📝 Updating consolidated package versions..."
just set-version "{{version}}"
# Trigger: main's ruleset rejects direct pushes ("Changes must be made
# through a pull request").
# Why: the version bump must land on main before the tag is cut, so it
# rides a release PR that is rebase-merged (the repo disallows merge
# commits).
# Outcome: the bump commit gets a new SHA on main; the tag is created on
# that rebased commit, found by its commit subject.
COMMIT_SUBJECT="chore: update version to $VERSION_NUM for {{version}} beta release"
git checkout -b "release/{{version}}"
# Commit version update
git add \
src/basic_memory/__init__.py \
server.json \
@@ -534,57 +451,23 @@ beta version:
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
git commit -s -m "$COMMIT_SUBJECT"
echo "📤 Opening release PR..."
git push -u origin "release/{{version}}"
gh pr create --title "chore(core): release {{version}}" \
--body "Version bump for {{version}} beta."
# Trigger: the PR may not be mergeable synchronously (merge gates,
# required checks added later, or GitHub still computing mergeability).
# Why: the tag must point at the bump commit on main, so the recipe
# cannot tag until the merge has actually landed.
# Outcome: try a direct rebase-merge, fall back to queueing auto-merge,
# then poll main for the rebased bump commit before tagging.
if ! gh pr merge "release/{{version}}" --rebase --delete-branch; then
echo "⚠️ Direct merge did not complete (merge gates pending?). Queueing auto-merge..."
gh pr merge "release/{{version}}" --rebase --delete-branch --auto
fi
echo "⏳ Waiting for the bump commit to land on main..."
TAG_COMMIT=""
for _ in $(seq 1 60); do
git fetch origin main --quiet
TAG_COMMIT=$(git log FETCH_HEAD --fixed-strings --grep "$COMMIT_SUBJECT" --format='%H' -1)
[[ -n "$TAG_COMMIT" ]] && break
sleep 5
done
if [[ -z "$TAG_COMMIT" ]]; then
echo "❌ Bump commit not on main after 5 minutes (merge still pending?)."
echo " Once the release PR merges, finish the release manually:"
echo " git fetch origin main"
echo " git tag {{version}} \$(git log FETCH_HEAD --fixed-strings --grep \"$COMMIT_SUBJECT\" --format='%H' -1)"
echo " git push origin {{version}}"
exit 1
fi
git checkout main
git pull --ff-only origin main
git branch -D "release/{{version}}" 2>/dev/null || true
echo "🏷️ Creating tag {{version}} at $TAG_COMMIT..."
git tag "{{version}}" "$TAG_COMMIT"
git commit -s -m "chore: update version to $VERSION_NUM for {{version}} beta release"
# Create and push tag
echo "🏷️ Creating tag {{version}}..."
git tag "{{version}}"
echo "📤 Pushing to GitHub..."
git push origin main
git push origin "{{version}}"
echo "✅ Beta release {{version}} created successfully!"
echo "📦 GitHub Actions will build and publish to PyPI as pre-release"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo "📥 Install with: uv tool install basic-memory --pre"
echo ""
echo "📝 REMINDER: For stable releases, update documentation sites:"
echo " 1. docs.basicmemory.com - Add a What's New page under content/2.whats-new/"
echo " and bump the badge in content/index.md (see that repo's CLAUDE.md)"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " See: .claude/commands/release/release.md for detailed instructions"
@@ -6,14 +6,14 @@
},
"metadata": {
"description": "Official plugins from Basic Machines for knowledge management and AI-assisted development",
"version": "0.22.1"
"version": "0.21.6"
},
"plugins": [
{
"name": "basic-memory",
"source": "./",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph \u2014 session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.22.1",
"version": "0.21.6",
"author": {
"name": "Basic Machines"
},
@@ -1,7 +1,7 @@
{
"name": "basic-memory",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph \u2014 session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.22.1",
"version": "0.21.6",
"author": {
"name": "Basic Machines"
},
+1 -1
View File
@@ -24,7 +24,7 @@ Memory's durable graph**, rather than a memory layer of its own. See
configures the project for the plugin: maps it to a Basic Memory project (picking
an existing one or creating a new one), seeds the `session`/`decision`/`task`
schemas into the project, installs the shared `memory-*` skills via
`npx skills add basicmachines-co/basic-memory/skills` (the plugin doesn't
`npx skills add basicmachines-co/basic-memory --path skills` (the plugin doesn't
vendor its own copies — `skills/` is the single source of truth, shared with
OpenClaw), optionally learns the project's placement conventions, and enables the
capture reflexes. Writes the `basicMemory` block to
-55
View File
@@ -1,55 +0,0 @@
---
title: Manpage
type: schema
entity: Manpage
version: 1
schema:
gotcha?(array): string, sharp edges and surprising behavior learned from live verification
example?(array): string, worked examples beyond the generated synopsis
pattern?(array): string, recommended idioms and usage patterns
bug?(array): string, known defects affecting this surface, with issue links
see_also?(array): Entity, related manual pages — the SEE ALSO graph
settings:
validation: warn
frontmatter:
section(enum, Unix manual section number): [1, 3, 5, 7, 8]
name: string, page name without section suffix (e.g. write-note)
summary: string, one-line NAME description
generated?(enum, who owns the mechanical sections): [registry, typer, hand]
tool?: string, MCP tool this page documents (section 3 pages)
command?: string, CLI command this page documents (section 1 pages)
verified?: string, version and path that verified this page (e.g. 0.21.6 mcp+cli)
since?: string, version this surface first appeared
---
# Manpage
A **ManpageNote** is one page of a Unix-style manual implemented as Basic
Memory notes (issue #952): commands in section 1, MCP tools in section 3,
file formats in section 5, concepts in section 7, admin in section 8. The
manual becomes a knowledge graph — `SEE ALSO` entries are typed relations,
and pages are found by structured recall:
`search_notes(metadata_filters={"type": "manpage", "section": 3})`.
This schema is an opt-in seed for documentation projects; the canonical
manual lives in the Basic Memory team workspace `manual` project.
## What makes a good ManpageNote
- **NAME / SYNOPSIS / DESCRIPTION** — classic man-page structure, with
PARAMETERS, MCP USAGE, CLI EQUIVALENT, EXAMPLES, GOTCHAS, SEE ALSO where
applicable.
- **Verified examples** — EXAMPLES contain only commands that actually ran;
the `verified` field records the version and path (mcp, cli, or both).
- **generated** — declares regeneration ownership: `registry` (from the MCP
tool registry) and `typer` (from CLI help) pages get mechanical sections
rewritten; curated sections (EXAMPLES, GOTCHAS, SEE ALSO, observations)
are never overwritten.
- **gotcha / bug observations** — field knowledge accumulates on pages
without being clobbered by regeneration; bugs link their tracking issues.
## Frontmatter
`type: manpage` plus `section` makes the manual queryable like `man -k`:
by section, by `tool`, by `command`, or by missing/stale `verified` stamps.
Validation is `warn`, never blocking.
+1 -1
View File
@@ -156,7 +156,7 @@ git ls-files skills/ | grep -q memory- && echo "source repo - skip install"
Otherwise, run from the project root:
```
npx skills add basicmachines-co/basic-memory/skills
npx skills add basicmachines-co/basic-memory --path skills
```
This installs the canonical `memory-*` skills into the user's skills directory — the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "0.22.1",
"version": "0.21.6",
"description": "A Codex-native bridge to Basic Memory for durable engineering context, decisions, and resumable checkpoints.",
"author": {
"name": "Basic Machines",
-6
View File
@@ -76,10 +76,6 @@ addopts = "--cov=basic_memory --cov-report term-missing"
testpaths = ["tests", "test-int"]
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
# Any test hanging >120s fails with a stack dump instead of stalling the CI job
# until the runner times out (the FastMCP/asyncpg cleanup-hang family).
timeout = 120
timeout_method = "thread"
filterwarnings = [
"ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning:testcontainers\\.core\\.waiting_utils",
"ignore:The default datetime adapter is deprecated as of Python 3\\.12.*:DeprecationWarning:aiosqlite\\.core",
@@ -119,8 +115,6 @@ dev = [
"ty>=0.0.18",
"cst-lsp>=0.1.3",
"libcst>=1.8.6",
"pytest-timeout>=2.4.0",
"pytest-split>=0.11.0",
]
[tool.hatch.version]
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env python3
"""Seed and refresh shared pytest-testmon data for Git worktrees."""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import NamedTuple
TESTMON_FILENAMES = (".testmondata", ".testmondata-shm", ".testmondata-wal")
TESTMON_CACHE_ENV = "BM_TESTMON_CACHE_DIR"
class TestmonCacheResult(NamedTuple):
status: str
source_dir: Path
destination_dir: Path
copied: tuple[Path, ...]
def _run_git(args: list[str], cwd: Path) -> str:
return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
def resolve_repo_root(repo_root: Path | None = None) -> Path:
if repo_root is not None:
return repo_root.expanduser().resolve()
return Path(_run_git(["rev-parse", "--show-toplevel"], Path.cwd())).resolve()
def resolve_cache_dir(repo_root: Path, cache_dir: Path | None = None) -> Path:
if cache_dir is not None:
return cache_dir.expanduser().resolve()
if env_cache_dir := os.environ.get(TESTMON_CACHE_ENV):
return Path(env_cache_dir).expanduser().resolve()
git_common_dir = Path(_run_git(["rev-parse", "--git-common-dir"], repo_root))
if not git_common_dir.is_absolute():
git_common_dir = repo_root / git_common_dir
return git_common_dir.resolve() / "testmon-cache" / "main"
def _testmon_datafile(directory: Path) -> Path:
return directory / ".testmondata"
def _testmon_files(directory: Path) -> list[Path]:
return [
directory / filename for filename in TESTMON_FILENAMES if (directory / filename).is_file()
]
def _remove_path(path: Path) -> None:
if path.is_dir():
shutil.rmtree(path)
elif path.exists():
path.unlink()
def _copy_testmon_files(source_dir: Path, destination_dir: Path) -> tuple[Path, ...]:
destination_dir.mkdir(parents=True, exist_ok=True)
copied: list[Path] = []
for source in _testmon_files(source_dir):
destination = destination_dir / source.name
shutil.copy2(source, destination)
copied.append(destination)
return tuple(copied)
def seed_testmon_data(repo_root: Path, cache_dir: Path) -> TestmonCacheResult:
local_datafile = _testmon_datafile(repo_root)
shared_datafile = _testmon_datafile(cache_dir)
if local_datafile.exists():
return TestmonCacheResult(
status="exists",
source_dir=cache_dir,
destination_dir=repo_root,
copied=(),
)
if not shared_datafile.exists():
return TestmonCacheResult(
status="missing",
source_dir=cache_dir,
destination_dir=repo_root,
copied=(),
)
# A worktree with sidecars but no main database is stale; replace the set
# together so SQLite never sees a mixed local/cache snapshot.
for filename in TESTMON_FILENAMES:
_remove_path(repo_root / filename)
copied = _copy_testmon_files(cache_dir, repo_root)
return TestmonCacheResult(
status="seeded",
source_dir=cache_dir,
destination_dir=repo_root,
copied=copied,
)
def refresh_testmon_data(repo_root: Path, cache_dir: Path) -> TestmonCacheResult:
local_datafile = _testmon_datafile(repo_root)
if not local_datafile.exists():
raise FileNotFoundError(
f"No local pytest-testmon data at {local_datafile}; run tests first."
)
cache_parent = cache_dir.parent
cache_parent.mkdir(parents=True, exist_ok=True)
temp_dir = Path(tempfile.mkdtemp(prefix=f".{cache_dir.name}.", dir=cache_parent))
backup_dir = cache_parent / f".{cache_dir.name}.previous-{os.getpid()}"
copied: tuple[Path, ...] = ()
try:
copied = _copy_testmon_files(repo_root, temp_dir)
_remove_path(backup_dir)
if cache_dir.exists():
cache_dir.rename(backup_dir)
try:
temp_dir.rename(cache_dir)
except Exception:
if backup_dir.exists() and not cache_dir.exists():
backup_dir.rename(cache_dir)
raise
finally:
_remove_path(temp_dir)
_remove_path(backup_dir)
return TestmonCacheResult(
status="refreshed",
source_dir=repo_root,
destination_dir=cache_dir,
copied=tuple(cache_dir / path.name for path in copied),
)
def _print_seed_result(result: TestmonCacheResult) -> None:
if result.status == "seeded":
print(f"Seeded pytest-testmon data from {result.source_dir} into {result.destination_dir}")
elif result.status == "exists":
print(f"Local pytest-testmon data already exists at {result.destination_dir}")
elif result.status == "missing":
print(
f"No shared pytest-testmon baseline at {result.source_dir}; "
"run `just testmon-refresh` after a full backend test run to create one."
)
else:
raise ValueError(f"Unexpected seed result: {result.status}")
def _print_refresh_result(result: TestmonCacheResult) -> None:
print(f"Published pytest-testmon data from {result.source_dir} to {result.destination_dir}")
def _print_status(repo_root: Path, cache_dir: Path) -> None:
print(f"Repo root: {repo_root}")
print(f"Worktree data: {_testmon_datafile(repo_root)}")
print(f"Shared cache: {_testmon_datafile(cache_dir)}")
print(f"Worktree ready: {_testmon_datafile(repo_root).exists()}")
print(f"Cache ready: {_testmon_datafile(cache_dir).exists()}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo-root",
type=Path,
help="Repository root to operate on (default: git rev-parse --show-toplevel)",
)
parser.add_argument(
"--cache-dir",
type=Path,
help=(
"Shared testmon cache directory "
f"(default: ${TESTMON_CACHE_ENV} or <git-common-dir>/testmon-cache/main)"
),
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("seed", help="Copy shared testmon data into this worktree if missing")
subparsers.add_parser("refresh", help="Publish this worktree's testmon data to the cache")
subparsers.add_parser("status", help="Show local and shared testmon data paths")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
repo_root = resolve_repo_root(args.repo_root)
cache_dir = resolve_cache_dir(repo_root, args.cache_dir)
if args.command == "seed":
_print_seed_result(seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir))
return 0
if args.command == "refresh":
_print_refresh_result(refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir))
return 0
if args.command == "status":
_print_status(repo_root=repo_root, cache_dir=cache_dir)
return 0
parser.error(f"Unknown command: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.22.1",
"version": "0.21.6",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.22.1",
"version": "0.21.6",
"runtimeHint": "uvx",
"runtimeArguments": [
{
+3 -3
View File
@@ -54,13 +54,13 @@ Users can install or update skills with the [Skills CLI](https://github.com/verc
```bash
# Install all skills
npx skills add basicmachines-co/basic-memory/skills
npx skills add basicmachines-co/basic-memory --path skills
# Install a specific skill
npx skills add basicmachines-co/basic-memory/skills --skill memory-tasks
npx skills add basicmachines-co/basic-memory --path skills --skill memory-tasks
# Install for a specific agent
npx skills add basicmachines-co/basic-memory/skills --agent claude
npx skills add basicmachines-co/basic-memory --path skills --agent claude
```
## Adding a New Skill
+5 -5
View File
@@ -47,16 +47,16 @@ Install or update skills using the [Skills CLI](https://github.com/vercel-labs/s
```bash
# Install all skills
npx skills add basicmachines-co/basic-memory/skills
npx skills add basicmachines-co/basic-memory --path skills
# Install a specific skill
npx skills add basicmachines-co/basic-memory/skills --skill memory-tasks
npx skills add basicmachines-co/basic-memory --path skills --skill memory-tasks
# Install all skills for a specific agent
npx skills add basicmachines-co/basic-memory/skills --agent claude
npx skills add basicmachines-co/basic-memory --path skills --agent claude
# List available skills without installing
npx skills add basicmachines-co/basic-memory/skills --list
npx skills add basicmachines-co/basic-memory --path skills --list
# Check for updates
npx skills check
@@ -67,7 +67,7 @@ npx skills update
Skills are installed to your agent's skills directory (e.g., `~/.claude/skills/` for Claude Code global, or `.claude/skills/` for project-scoped).
If your installed Skills CLI cannot load `basicmachines-co/basic-memory/skills`, update the CLI or copy the `memory-*` directories manually.
If your installed Skills CLI does not support `--path`, copy the `memory-*` directories manually for now. Phase 2 will add a first-class Codex/package install path.
### Claude Desktop (claude.ai)
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.22.1"
__version__ = "0.21.6"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -10,18 +10,10 @@ Key improvements:
- Simplified caching strategies
"""
import os
import pathlib
from fastapi import APIRouter, HTTPException, Response, Path
from loguru import logger
import logfire
from basic_memory.ignore_utils import (
IGNORED_PATH_REJECTION_DETAIL,
load_gitignore_patterns,
should_ignore_path,
)
from basic_memory.deps import (
EntityServiceV2ExternalDep,
SearchServiceV2ExternalDep,
@@ -32,7 +24,6 @@ from basic_memory.deps import (
EntityRepositoryV2ExternalDep,
RelationRepositoryV2ExternalDep,
ProjectExternalIdPathDep,
SyncServiceV2ExternalDep,
TaskSchedulerDep,
)
from basic_memory.schemas import DeleteEntitiesResponse
@@ -49,10 +40,8 @@ from basic_memory.schemas.v2 import (
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
OrphanEntitiesResponse,
SyncFileRequest,
)
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
from basic_memory.utils import validate_project_path
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
@@ -247,187 +236,6 @@ async def resolve_identifier(
return result
## Single-file sync endpoint
def _canonical_file_path(home: pathlib.Path, segments: list[str]) -> str | None:
"""Resolve the actual on-disk casing of a file path under the project home.
Trigger: case-insensitive filesystems (macOS/Windows) pass existence checks for
wrong-cased paths like 'notes/Disk-Note.md' when the file is 'notes/disk-note.md'.
Why: indexing the caller-supplied casing misses the existing DB row keyed by the
on-disk path and inserts a duplicate entity under the wrong-cased path.
Outcome: each segment is matched against real directory entries — exact name first
(so distinct case-variant files on case-sensitive filesystems stay distinct),
then a unique case-insensitive match. Returns None when any segment cannot be
matched to exactly one entry, including missing files. Traversal stops at the
project boundary: a directory whose resolved path escapes the project home is
never scanned.
"""
resolved_home = home.resolve()
current = home
canonical_segments: list[str] = []
for segment in segments:
# Trigger: a previously matched segment may be a symlink whose target lies
# outside the project root (e.g. wrong-cased 'LINK' matched the on-disk
# 'link' -> /tmp/outside on a case-sensitive filesystem).
# Why: os.scandir follows symlinked directories, so continuing would read
# directory contents outside the project boundary even though the
# post-canonicalization containment check rejects the request later.
# Outcome: bail before scanning the moment resolution escapes the home.
if not current.resolve().is_relative_to(resolved_home):
return None
try:
with os.scandir(current) as entries_iter:
entries = [entry.name for entry in entries_iter]
except OSError:
# A parent segment resolved to a non-directory (or vanished): no canonical
# path exists for the remaining segments.
return None
if segment in entries:
matched = segment
else:
matches = [entry for entry in entries if entry.lower() == segment.lower()]
if len(matches) != 1:
return None
matched = matches[0]
canonical_segments.append(matched)
current = current / matched
return "/".join(canonical_segments)
@router.post("/sync-file", response_model=EntityResponseV2)
async def sync_file(
data: SyncFileRequest,
project_id: ProjectExternalIdPathDep,
sync_service: SyncServiceV2ExternalDep,
project_config: ProjectConfigV2ExternalDep,
search_service: SearchServiceV2ExternalDep,
app_config: AppConfigDep,
) -> EntityResponseV2:
"""Index a single markdown file that exists on disk but is not indexed yet.
Recovery path for files written directly to disk before the watcher indexed
them (#581): callers such as edit_note can index the exact file and retry
identifier resolution without running a full project sync.
Args:
data: Request containing the markdown file path relative to project root
Returns:
The indexed entity
Raises:
HTTPException: 400 if the path escapes the project root, contains
non-normalized segments, matches the project ignore rules, or is
not markdown, 404 if the file does not exist on disk
"""
with logfire.span(
"api.request.knowledge.sync_file",
entrypoint="api",
domain="knowledge",
action="sync_file",
):
logger.info(f"API v2 request: sync_file file_path='{data.file_path}'")
if not validate_project_path(data.file_path, project_config.home):
raise HTTPException(
status_code=400,
detail=f"File path '{data.file_path}' is not allowed - "
"paths must stay within project boundaries",
)
# Trigger: segments like './' or '//' survive the traversal check above
# Why: a non-normalized path would index under a non-canonical DB key
# Outcome: reject fail-fast instead of guessing the canonical form
segments = data.file_path.replace("\\", "/").split("/")
if any(segment in ("", ".") for segment in segments):
raise HTTPException(
status_code=400,
detail=f"File path '{data.file_path}' is not normalized - "
"segments like './' or '//' are not allowed",
)
# Canonicalize to the actual on-disk casing so the DB lookup below hits the
# row keyed by the real path instead of inserting a wrong-cased duplicate.
file_path = _canonical_file_path(project_config.home, segments)
if file_path is None:
raise HTTPException(
status_code=404, detail=f"File not found on disk: '{data.file_path}'"
)
# Trigger: canonicalization rewrote a segment to its on-disk form, and that
# segment may be a symlink. The pre-check above validated the ORIGINAL
# request path — on a case-sensitive filesystem 'LINK/secret.md' does not
# exist, so resolve() cannot follow the real 'link' symlink and the check
# passes even when 'link' points outside the project root.
# Why: indexing through an escaping symlink would read and index content
# outside the project boundary — and even an is_file() existence probe on
# the joined path would follow the symlink and stat its target, so
# containment must hold BEFORE any filesystem probe that follows symlinks.
# Path.resolve() only walks symlink names (readlink); it never opens or
# stats the final target, so it is safe to run pre-containment.
# Outcome: the canonical path is re-validated and the fully-resolved absolute
# target must stay inside the resolved project home; escapes get a 400
# before the file-existence probe below ever touches the target.
resolved_target = (project_config.home / file_path).resolve()
if not validate_project_path(file_path, project_config.home) or not (
resolved_target.is_relative_to(project_config.home.resolve())
):
raise HTTPException(
status_code=400,
detail=f"File path '{data.file_path}' is not allowed - "
"paths must stay within project boundaries",
)
# Containment holds, so probing the resolved target cannot leave the project.
if not resolved_target.is_file():
raise HTTPException(
status_code=404, detail=f"File not found on disk: '{data.file_path}'"
)
# Trigger: the canonical path matches the .bmignore / project .gitignore rules
# Why: scan and watch flows filter ignored files before they ever reach the
# indexer; indexing one here would bypass the ignored-file contract and
# make hidden or gitignored content searchable
# Outcome: the same should_ignore_path() rules apply to single-file sync
ignore_patterns = load_gitignore_patterns(project_config.home)
if should_ignore_path(
project_config.home / file_path, project_config.home, ignore_patterns
):
raise HTTPException(
status_code=400,
detail=f"File path '{data.file_path}' {IGNORED_PATH_REJECTION_DETAIL} "
"and cannot be indexed",
)
if not sync_service.file_service.is_markdown(file_path):
raise HTTPException(
status_code=400,
detail=f"Only markdown files can be indexed: '{data.file_path}'",
)
# Trigger: the file may already have a DB row (e.g. modified on disk after indexing)
# Why: the indexer needs to know whether to insert or update the entity
# Outcome: new is computed from the database instead of assumed by the caller
existing = await sync_service.entity_repository.get_by_file_path(file_path)
synced = await sync_service.sync_one_markdown_file(
file_path, new=existing is None, index_search=True
)
# Trigger: semantic search is enabled and the entity index was just refreshed
# Why: the project sync flow awaits sync_entity_vectors_batch() inline after
# indexing changed files (SyncService.sync); without the single-entity
# equivalent, a note recovered via sync-file stays missing or stale in
# semantic search until a later edit or full project sync
# Outcome: vectors refresh synchronously before the response returns,
# mirroring the sync flow instead of the out-of-band scheduler
if app_config.semantic_search_enabled:
await search_service.sync_entity_vectors_batch([synced.entity.id])
result = EntityResponseV2.model_validate(synced.entity)
logger.info(
f"API v2 response: sync_file file_path='{file_path}' external_id={result.external_id}"
)
return result
## Read endpoints
@@ -193,7 +193,7 @@ async def add_project(
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
message=f"Project '{new_project.name}' added successfully",
status="success",
default=new_project.is_default or False,
default=project_data.set_default,
new_project=ProjectItem(
id=new_project.id,
external_id=new_project.external_id,
@@ -320,20 +320,7 @@ async def resolve_project_identifier(
)
if not project:
detail = f"Project not found: '{data.identifier}'"
# Trigger: resolution missed and the projects table is empty.
# Why: a fresh install bootstraps config.json's default project before any
# reconciliation has created database rows (the one-shot CLI never runs
# the server lifespan), so the first read fails on the configured
# default and the bare not-found message reads as a broken install
# rather than a missing first-run step (#974 follow-up).
# Outcome: the error names the setup command instead.
if not await project_repository.find_all(limit=1, use_load_options=False):
detail = (
f"{detail}. No projects are set up yet — run "
"'basic-memory project add <name> <path>' to create one."
)
raise HTTPException(status_code=404, detail=detail)
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
return ProjectResolveResponse(
external_id=project.external_id,
@@ -572,10 +559,12 @@ async def set_default_project_by_id(
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
try:
# Get the old default project from database. It may be absent during
# bootstrap/recovery (no default row yet); that is a valid state, not an
# error, so we only echo it back when one exists.
# Get the old default project from database
default_project = await project_repository.get_default_project()
if not default_project:
raise HTTPException( # pragma: no cover
status_code=404, detail="No default project is currently set"
)
# Get the new default project by external_id
new_default_project = await project_repository.get_by_external_id(project_id)
@@ -587,27 +576,17 @@ async def set_default_project_by_id(
# Set as default using project name (service layer still uses names internally)
await project_service.set_default_project(new_default_project.name)
# Trigger: a previous default existed
# Why: ProjectStatusResponse.old_project is Optional; the no-default
# bootstrap case must succeed with old_project=None
# Outcome: response echoes the prior default only when there was one
old_project = (
ProjectItem(
return ProjectStatusResponse(
message=f"Project '{new_default_project.name}' set as default successfully",
status="success",
default=True,
old_project=ProjectItem(
id=default_project.id,
external_id=default_project.external_id,
name=default_project.name,
path=default_project.path,
is_default=False,
)
if default_project
else None
)
return ProjectStatusResponse(
message=f"Project '{new_default_project.name}' set as default successfully",
status="success",
default=True,
old_project=old_project,
),
new_project=ProjectItem(
id=new_default_project.id,
external_id=new_default_project.external_id,
-1
View File
@@ -147,7 +147,6 @@ async def to_graph_context(
from_entity_id=item.from_id,
from_entity_external_id=from_ext_id,
to_entity=to_title,
to_name=item.to_name,
to_entity_id=item.to_id,
to_entity_external_id=to_ext_id,
created_at=item.created_at,
-3
View File
@@ -83,11 +83,8 @@ def app_callback(
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
# Skip for 'reset' command - it manages its own database lifecycle
# Skip for 'man' - it only copies packaged files; a broken local database
# must not block installing the offline docs
skip_init_commands = {
"doctor",
"man",
"mcp",
"status",
"sync",
-15
View File
@@ -168,20 +168,6 @@ def _manual_update_hint(source: InstallSource) -> str:
)
def _preload_lazy_console_modules() -> None:
"""Import modules the post-upgrade output path defers until print time.
Trigger: an in-place upgrade is about to replace this installation on disk.
Why: rich and typer defer some imports until print/excepthook time; once
`brew upgrade` / `uv tool upgrade` removes the running version's files,
those imports raise ModuleNotFoundError and the final status message
crashes the exiting process.
Outcome: import the deferred modules now, while the files still exist.
"""
import rich._emoji_codes # noqa: F401
import typer.rich_utils # noqa: F401
def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
"""Persist the timestamp for the most recent attempted update check."""
config = config_manager.load_config()
@@ -302,7 +288,6 @@ def run_auto_update(
else BREW_UPGRADE_TIMEOUT_SECONDS
)
_preload_lazy_console_modules()
install_result = _run_subprocess(
command,
timeout_seconds=timeout,
@@ -4,7 +4,6 @@ from . import ci, status, db, doctor, import_memory_json, mcp, import_claude_con
from . import (
import_claude_projects,
import_chatgpt,
man,
tool,
project,
format,
@@ -30,5 +29,4 @@ __all__ = [
"schema",
"update",
"workspace",
"man",
]
+2 -12
View File
@@ -34,10 +34,8 @@ from basic_memory.ci.project_updates import (
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
# MCP tool functions are imported inside the async helpers below: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
console = Console()
@@ -254,10 +252,6 @@ async def seed_project_update_schemas(
refresh: bool = False,
) -> list[str]:
"""Seed Auto BM schema notes without overwriting customized schemas."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
seeded: list[str] = []
routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace)
for spec in schema_seed_specs():
@@ -301,10 +295,6 @@ async def publish_project_update_note(
note: ProjectUpdateNote,
) -> dict[str, Any]:
"""Search by idempotency key and then upsert the deterministic note path."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
routed_project = _routed_project(
project=config.project,
project_id=config.project_id,
@@ -32,33 +32,14 @@ def _rclone_exclude_filters(pattern: str) -> list[str]:
return [f"- {path_pattern}", f"- {path_pattern}/**"]
def _workspace_id_header(workspace_id: str | None) -> dict[str, str]:
"""Header that routes a /tenant/mount/* request to a specific tenant.
The mount endpoints resolve the workspace from X-Workspace-ID (validating
membership + subscription) and fall back to the user's default tenant when
it is absent — so omitting it preserves the original default-tenant behavior.
"""
return {"X-Workspace-ID": workspace_id} if workspace_id else {}
async def get_mount_info(*, workspace_id: str | None = None) -> TenantMountInfo:
"""Get tenant mount info (bucket name + tenant id) from the cloud API.
Args:
workspace_id: Tenant id of the target workspace. When omitted, the API
uses the authenticated user's default tenant.
"""
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(
method="GET",
url=f"{host_url}/tenant/mount/info",
headers=_workspace_id_header(workspace_id),
)
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
return TenantMountInfo.model_validate(response.json())
except Exception as e:
@@ -66,24 +47,13 @@ async def get_mount_info(*, workspace_id: str | None = None) -> TenantMountInfo:
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
"""Generate scoped S3 credentials for syncing a specific tenant's bucket.
Args:
tenant_id: Tenant id whose bucket-scoped credentials to mint. Routed via
X-Workspace-ID so team workspaces get their own bucket's credentials.
"""
"""Generate scoped credentials for syncing."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
# The mount endpoints resolve X-Workspace-ID by matching the workspace's
# tenant_id, so passing a tenant_id here is the correct routing key.
response = await make_api_request(
method="POST",
url=f"{host_url}/tenant/mount/credentials",
headers=_workspace_id_header(tenant_id),
)
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
return MountCredentials.model_validate(response.json())
except Exception as e:
@@ -26,52 +26,15 @@ from basic_memory.cli.commands.cloud.bisync_commands import (
generate_mount_credentials,
get_mount_info,
)
from basic_memory.cli.commands.cloud.rclone_config import (
configure_rclone_remote,
rclone_remote_exists,
remote_name_for_workspace,
)
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
from basic_memory.cli.commands.cloud.rclone_installer import (
RcloneInstallError,
install_rclone,
)
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import (
WorkspaceInfo,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_exact_identifier,
)
console = Console()
def _resolve_setup_workspace(identifier: str) -> WorkspaceInfo:
"""Resolve a workspace identifier (slug, name, or tenant_id) for setup.
Errors with copyable choices when the identifier matches zero or multiple
workspaces, so the user can disambiguate.
"""
workspaces = run_with_cleanup(get_available_workspaces())
if not workspaces:
console.print("[red]No accessible cloud workspaces found for this account[/red]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if workspace_matches_exact_identifier(ws, identifier)]
if len(matches) == 1:
return matches[0]
if not matches:
console.print(f"[red]No workspace matches '{identifier}'[/red]")
console.print("\nAvailable workspaces:")
console.print(format_workspace_choices(workspaces))
else:
console.print(f"[red]'{identifier}' matches multiple workspaces[/red]")
console.print("\nDisambiguate with the workspace slug or tenant_id:")
console.print(format_workspace_selection_choices(matches))
raise typer.Exit(1)
@cloud_app.command()
def login():
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
@@ -201,28 +164,13 @@ def status() -> None:
@cloud_app.command("setup")
def setup(
workspace: str | None = typer.Option(
None,
"--workspace",
help="Set up sync for a specific workspace (slug, name, or tenant_id). "
"Omit for your default workspace.",
),
force: bool = typer.Option(
False,
"--force",
help="Reconfigure an rclone remote that already exists (mints new credentials).",
),
) -> None:
def setup() -> None:
"""Set up cloud sync by installing rclone and configuring credentials.
Run once per workspace you sync. The default workspace uses the
'basic-memory-cloud' remote; other (e.g. Team) workspaces each get their own
tenant-scoped remote, since Tigris credentials are bucket-scoped.
After setup, use the cloud sync commands:
bm cloud pull --name <name> # fetch cloud changes (Team-safe)
bm cloud push --name <name> # upload local changes (Team-safe)
After setup, use project commands for syncing:
bm project add <name> --cloud --local-path ~/projects/<name>
bm project bisync --name <name> --resync # First time
bm project bisync --name <name> # Subsequent syncs
"""
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up cloud sync with rclone...\n")
@@ -232,60 +180,35 @@ def setup(
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# --- Resolve target workspace ---
# Trigger: --workspace given. Why: Tigris keys are tenant-scoped, so a
# non-default workspace needs its own bucket + remote. Outcome: scope the
# mount-info/credentials calls and name the remote after the workspace.
if workspace is not None:
target = _resolve_setup_workspace(workspace)
workspace_id: str | None = target.tenant_id
remote_name = remote_name_for_workspace(target.slug, is_default=target.is_default)
console.print(f"[dim]Workspace: {target.name} ({target.slug})[/dim]")
else:
workspace_id = None # default tenant
remote_name = remote_name_for_workspace(None, is_default=True)
# Trigger: the target rclone remote already exists.
# Why: re-running setup mints new credentials and overwrites the remote,
# which would silently repoint it — e.g. clobbering the shared
# basic-memory-cloud remote that served another tenant. Checked BEFORE
# minting so an abort wastes no credentials.
# Outcome: stop unless the user explicitly opts in with --force.
if rclone_remote_exists(remote_name) and not force:
console.print(f"[red]rclone remote '{remote_name}' is already configured.[/red]")
console.print(
"Re-running setup mints new credentials and overwrites it. "
"Pass --force to reconfigure."
)
raise typer.Exit(1)
# Step 2: Get tenant info (scoped to the target workspace when given)
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = run_with_cleanup(get_mount_info(workspace_id=workspace_id))
tenant_info = run_with_cleanup(get_mount_info())
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials for that tenant's bucket
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure the tenant's rclone remote
# Step 4: Configure rclone remote
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
configure_rclone_remote(
access_key=creds.access_key,
secret_key=creds.secret_key,
remote_name=remote_name,
)
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Configure sync for a project:")
console.print("1. Add a project with local sync path:")
console.print(" bm project add research --cloud --local-path ~/Documents/research")
console.print("\n Or configure sync for an existing project:")
console.print(" bm cloud sync-setup research ~/Documents/research")
console.print("\n2. Preview a pull (recommended):")
console.print(" bm cloud pull --name research --dry-run")
console.print("\n3. Fetch cloud changes / upload local changes:")
console.print(" bm cloud pull --name research")
console.print(" bm cloud push --name research")
console.print("\n2. Preview the initial sync (recommended):")
console.print(" bm project bisync --name research --resync --dry-run")
console.print("\n3. If all looks good, run the actual sync:")
console.print(" bm project bisync --name research --resync")
console.print("\n4. Subsequent syncs (no --resync needed):")
console.print(" bm project bisync --name research")
console.print(
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
)
@@ -293,8 +216,6 @@ def setup(
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
@@ -26,23 +26,13 @@ from basic_memory.cli.commands.cloud.rclone_commands import (
project_sync,
project_transfer,
)
from basic_memory.cli.commands.cloud.rclone_config import (
DEFAULT_RCLONE_REMOTE,
rclone_remote_exists,
remote_name_for_workspace,
)
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import (
WorkspaceInfo,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_exact_identifier,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import generate_permalink, normalize_project_path
@@ -99,41 +89,12 @@ def _require_cloud_credentials(config: BasicMemoryConfig) -> None:
raise typer.Exit(1)
async def _get_workspace_for_project(
name: str,
config: BasicMemoryConfig,
*,
workspace_override: str | None = None,
) -> WorkspaceInfo:
"""Resolve the cloud workspace targeted by a project-scoped sync command.
``workspace_override`` (a slug, name, or tenant_id, e.g. from ``--workspace``)
takes precedence over config, letting the user disambiguate a project name
that exists in more than one workspace.
"""
async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
"""Resolve the cloud workspace targeted by a project-scoped sync command."""
workspaces = await get_available_workspaces()
if not workspaces:
raise ValueError("No accessible cloud workspaces found for this account")
# An explicit override wins over config — this is how the user disambiguates.
if workspace_override is not None:
matches = [
item
for item in workspaces
if workspace_matches_exact_identifier(item, workspace_override)
]
if len(matches) == 1:
return matches[0]
if not matches:
raise ValueError(
f"No accessible workspace matches '{workspace_override}'.\n"
f"{format_workspace_choices(workspaces)}"
)
raise ValueError(
f"'{workspace_override}' matches multiple workspaces; use a slug or tenant_id:\n"
f"{format_workspace_selection_choices(matches)}"
)
entry = config.projects.get(name)
workspace_id = entry.workspace_id if entry and entry.workspace_id else config.default_workspace
if workspace_id:
@@ -186,15 +147,9 @@ def _require_personal_workspace(
return workspace
async def _get_cloud_project(name: str, *, workspace_id: str | None = None) -> ProjectItem | None:
"""Fetch a project by name from the cloud API.
``workspace_id`` routes the lookup to a specific tenant so the project
metadata comes from the same workspace the transfer targets (otherwise
get_client would resolve the workspace from config/default and could read a
different tenant — see #920 review).
"""
async with get_client(project_name=name, workspace=workspace_id) as client:
async def _get_cloud_project(name: str) -> ProjectItem | None:
"""Fetch a project by name from the cloud API."""
async with get_client(project_name=name) as client:
projects_list = await ProjectClient(client).list_projects()
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
@@ -203,17 +158,10 @@ async def _get_cloud_project(name: str, *, workspace_id: str | None = None) -> P
def _get_sync_project(
name: str,
config: BasicMemoryConfig,
project_data: ProjectItem,
*,
remote_name: str = DEFAULT_RCLONE_REMOTE,
name: str, config: BasicMemoryConfig, project_data: ProjectItem
) -> tuple[SyncProject, str | None]:
"""Build a SyncProject and resolve local_sync_path from config.
``remote_name`` selects which tenant-scoped rclone remote the project routes
through (default tenant vs a team workspace remote).
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
"""
sync_entry = config.projects.get(name)
@@ -229,7 +177,6 @@ def _get_sync_project(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
remote_name=remote_name,
)
return sync_project, local_sync_path
@@ -258,10 +205,7 @@ def sync_project_command(
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
try:
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -315,64 +259,29 @@ def _run_directional_transfer(
on_conflict: ConflictStrategy,
dry_run: bool,
verbose: bool,
workspace: str | None = None,
) -> None:
"""Shared orchestration for `bm cloud push` / `bm cloud pull`.
Detects conflicts first, then aborts (the default) or applies the chosen
resolution. Uses additive `rclone copy`, so it never deletes on the
destination — safe for Team workspaces and therefore not gated.
Routes through the resolved workspace's own tenant-scoped rclone remote, so a
Team project reads/writes the right bucket (see #919).
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# --- Resolve the target workspace and its tenant-scoped remote ---
# Tigris credentials are bucket/tenant-scoped, so each workspace has its
# own rclone remote. Resolve which workspace this project belongs to
# (config or --workspace override) before touching any bucket.
try:
target_workspace = run_with_cleanup(
_get_workspace_for_project(name, config, workspace_override=workspace)
)
except Exception as exc:
console.print(f"[red]Error resolving workspace for project '{name}': {exc}[/red]")
raise typer.Exit(1)
remote_name = remote_name_for_workspace(
target_workspace.slug, is_default=target_workspace.is_default
)
# Trigger: the workspace's remote has not been configured yet.
# Why: provisioning mints tenant-scoped credentials and must be explicit
# (no surprise key generation); push/pull only transfer.
# Outcome: stop with the exact setup command for this workspace.
if not rclone_remote_exists(remote_name):
setup_target = (
"" if target_workspace.is_default else f" --workspace {target_workspace.slug}"
)
console.print(f"[red]Workspace '{target_workspace.slug}' is not set up for sync.[/red]")
console.print(f"\nRun: bm cloud setup{setup_target}")
raise typer.Exit(1)
# Get tenant info for bucket name, scoped to the resolved workspace
tenant_info = run_with_cleanup(get_mount_info(workspace_id=target_workspace.tenant_id))
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info from the same workspace we resolved above, so the
# project path and the bucket/remote all refer to one tenant.
# Get project info
with force_routing(cloud=True):
project_data = run_with_cleanup(
_get_cloud_project(name, workspace_id=target_workspace.tenant_id)
)
project_data = run_with_cleanup(_get_cloud_project(name))
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
sync_project, _ = _get_sync_project(name, config, project_data, remote_name=remote_name)
sync_project, _ = _get_sync_project(name, config, project_data)
# --- Detect before transferring ---
plan = project_diff(sync_project, bucket_name, direction)
@@ -446,11 +355,6 @@ def pull_project_command(
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
workspace: str | None = typer.Option(
None,
"--workspace",
help="Workspace (slug, name, or tenant_id) when the project name is ambiguous",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pulling"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
@@ -464,10 +368,9 @@ def pull_project_command(
bm cloud pull --name research
bm cloud pull --name research --dry-run
bm cloud pull --name research --on-conflict keep-cloud
bm cloud pull --name research --workspace acme
"""
_run_directional_transfer(
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose, workspace=workspace
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@@ -479,11 +382,6 @@ def push_project_command(
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
workspace: str | None = typer.Option(
None,
"--workspace",
help="Workspace (slug, name, or tenant_id) when the project name is ambiguous",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pushing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
@@ -498,10 +396,9 @@ def push_project_command(
bm cloud push --name research
bm cloud push --name research --dry-run
bm cloud push --name research --on-conflict keep-local
bm cloud push --name research --workspace acme
"""
_run_directional_transfer(
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose, workspace=workspace
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@@ -512,11 +409,7 @@ def bisync_project_command(
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Two-way mirror: local <-> cloud (bidirectional sync).
Personal workspaces only. This mirror can delete and overwrite files on both
sides, so on Team workspaces use `bm cloud pull` (fetch) / `bm cloud push`
(additive upload) instead.
"""Two-way sync: local <-> cloud (bidirectional sync).
Examples:
bm cloud bisync --name research --resync # First time
@@ -528,10 +421,7 @@ def bisync_project_command(
_require_personal_workspace(name, config)
try:
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -580,11 +470,7 @@ def check_project_command(
name: str = typer.Option(..., "--name", "--project", help="Project name to check"),
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
) -> None:
"""Verify file integrity between local and cloud (no changes made).
Personal workspaces only: check compares against the Personal workspace
mirror remote. On Team workspaces use `bm cloud pull --dry-run` /
`bm cloud push --dry-run` to preview differences instead.
"""Verify file integrity between local and cloud.
Example:
bm cloud check --name research
@@ -593,10 +479,7 @@ def check_project_command(
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -632,9 +515,6 @@ def bisync_reset(
) -> None:
"""Clear bisync state for a project.
Personal workspaces only (bisync is a Personal-workspace mirror; on Team
workspaces use `bm cloud pull` / `bm cloud push` instead).
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
Useful when bisync gets into an inconsistent state or when remote path changes.
"""
@@ -2,8 +2,7 @@
This module provides simplified, project-scoped rclone operations:
- Each project syncs independently
- Routes through the project's tenant-scoped remote (SyncProject.remote_name);
the default tenant keeps "basic-memory-cloud", others use their own (see #919)
- Uses single "basic-memory-cloud" remote (not tenant-specific)
- Balanced defaults from SPEC-8 Phase 4 testing
- Per-project bisync state tracking
@@ -114,15 +113,11 @@ class SyncProject:
name: Project name
path: Cloud path (e.g., "app/data/research")
local_sync_path: Local directory for syncing (optional)
remote_name: rclone remote serving this project's tenant bucket. Defaults
to the legacy single remote; team/non-default workspaces use their own
(see remote_name_for_workspace).
"""
name: str
path: str
local_sync_path: Optional[str] = None
remote_name: str = "basic-memory-cloud"
def get_bmignore_filter_path() -> Path:
@@ -184,13 +179,10 @@ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
is mounted at /app/data on the fly machine. We need to strip the /app/data/
prefix to get the actual S3 path within the bucket.
The remote name comes from the project so non-default/team workspaces route
through their own tenant-scoped remote (see #919).
"""
# Normalize path to strip /app/data/ mount point prefix
cloud_path = normalize_project_path(project.path).lstrip("/")
return f"{project.remote_name}:{bucket_name}/{cloud_path}"
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
# --- Directional transfer primitives (push / pull) ---
@@ -1,14 +1,11 @@
"""rclone configuration management for Basic Memory Cloud.
This module owns rclone remote configuration and naming. The default tenant uses
the "basic-memory-cloud" remote (from SPEC-20); non-default/team workspaces each
get their own tenant-scoped remote via remote_name_for_workspace (see #919),
since Tigris credentials are bucket-scoped.
This module provides simplified rclone configuration for SPEC-20.
Uses a single "basic-memory-cloud" remote for all operations.
"""
import configparser
import os
import re
import shutil
from pathlib import Path
from typing import Optional
@@ -64,65 +61,25 @@ def save_rclone_config(config: configparser.ConfigParser) -> None:
console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
# The default remote serves the account's default tenant (back-compat with SPEC-20,
# which used a single "basic-memory-cloud" remote). Non-default workspaces each get
# their own remote, since Tigris credentials are bucket/tenant-scoped (see #919).
DEFAULT_RCLONE_REMOTE = "basic-memory-cloud"
# rclone remote section names allow letters, digits, hyphens, and underscores.
# The slug comes from the cloud API (a trust boundary), so validate it before
# splicing it into a remote name to avoid a broken/unusable rclone.conf section.
_SAFE_SLUG = re.compile(r"^[A-Za-z0-9_-]+$")
def remote_name_for_workspace(slug: str | None, *, is_default: bool) -> str:
"""Return the rclone remote name for a workspace.
The default workspace keeps the legacy ``basic-memory-cloud`` remote so
existing setups keep working; other workspaces get ``basic-memory-cloud-<slug>``.
Raises:
RcloneConfigError: If a non-default workspace slug contains characters
that are not valid in an rclone remote name.
"""
if is_default or not slug:
return DEFAULT_RCLONE_REMOTE
if not _SAFE_SLUG.match(slug):
raise RcloneConfigError(
f"Workspace slug '{slug}' cannot be used as an rclone remote name "
"(allowed: letters, digits, hyphens, underscores)."
)
return f"{DEFAULT_RCLONE_REMOTE}-{slug}"
def rclone_remote_exists(remote_name: str) -> bool:
"""Return whether an rclone remote section is already configured."""
return load_rclone_config().has_section(remote_name)
def configure_rclone_remote(
access_key: str,
secret_key: str,
endpoint: str = "https://fly.storage.tigris.dev",
region: str = "auto",
remote_name: str = DEFAULT_RCLONE_REMOTE,
) -> str:
"""Configure an rclone remote for one tenant's bucket.
"""Configure single rclone remote named 'basic-memory-cloud'.
Each tenant (personal or team) has its own bucket-scoped credentials, so a
remote maps 1:1 to a tenant. The default tenant keeps ``basic-memory-cloud``;
other workspaces pass ``remote_name`` from :func:`remote_name_for_workspace`.
This is the simplified approach from SPEC-20 that uses one remote
for all Basic Memory cloud operations (not tenant-specific).
Args:
access_key: S3 access key ID
secret_key: S3 secret access key
endpoint: S3-compatible endpoint URL
region: S3 region (default: auto)
remote_name: rclone remote section name to write
Returns:
The remote name that was configured
The remote name: "basic-memory-cloud"
"""
# Backup existing config
backup_rclone_config()
@@ -130,21 +87,24 @@ def configure_rclone_remote(
# Load existing config
config = load_rclone_config()
# Add/update the remote section
if not config.has_section(remote_name):
config.add_section(remote_name)
# Single remote name (not tenant-specific)
REMOTE_NAME = "basic-memory-cloud"
config.set(remote_name, "type", "s3")
config.set(remote_name, "provider", "Other")
config.set(remote_name, "access_key_id", access_key)
config.set(remote_name, "secret_access_key", secret_key)
config.set(remote_name, "endpoint", endpoint)
config.set(remote_name, "region", region)
# Add/update the remote section
if not config.has_section(REMOTE_NAME):
config.add_section(REMOTE_NAME)
config.set(REMOTE_NAME, "type", "s3")
config.set(REMOTE_NAME, "provider", "Other")
config.set(REMOTE_NAME, "access_key_id", access_key)
config.set(REMOTE_NAME, "secret_access_key", secret_key)
config.set(REMOTE_NAME, "endpoint", endpoint)
config.set(REMOTE_NAME, "region", region)
# Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
# This prevents files with spaces like "Hello World.md" from being quoted
config.set(remote_name, "encoding", "Slash,InvalidUtf8")
config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
# Save updated config
save_rclone_config(config)
console.print(f"[green]Configured rclone remote: {remote_name}[/green]")
return remote_name
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
return REMOTE_NAME
@@ -3,10 +3,12 @@
import asyncio
from typing import Optional, TypeVar, Coroutine, Any
from mcp.server.fastmcp.exceptions import ToolError
import typer
from rich.console import Console
from basic_memory import db
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
@@ -29,9 +31,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
Returns:
The result of the coroutine
"""
# Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load
# at CLI import time — only when a command actually runs (#886).
from basic_memory import db
async def _with_cleanup() -> T:
try:
@@ -54,8 +53,6 @@ async def run_sync(
force_full: If True, force a full scan bypassing watermark optimization
run_in_background: If True, return immediately; if False, wait for completion
"""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
@@ -89,9 +86,6 @@ async def run_sync(
async def get_project_info(project: str):
"""Get project information via API endpoint."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
+7 -28
View File
@@ -1,27 +1,24 @@
"""Database management commands."""
# PEP 563 lazy annotations let signatures reference IndexProgress without importing
# the indexing stack at module load; reset/reindex import their heavy database and
# sync dependencies at call time so CLI startup stays fast (#886).
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING
import psutil
import typer
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from sqlalchemy.exc import OperationalError
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, ProjectMode
if TYPE_CHECKING:
from basic_memory.indexing import IndexProgress
from basic_memory.indexing import IndexProgress
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
console = Console()
@@ -162,13 +159,6 @@ async def _reindex_projects(app_config):
This ensures all database operations use the same event loop,
and proper cleanup happens when the function completes.
"""
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
try:
await reconcile_projects_with_config(app_config)
@@ -207,12 +197,6 @@ def reset(
),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
# Deferred: SQLAlchemy and the db module load only when a reset actually
# runs, not on every CLI start (#886).
from sqlalchemy.exc import OperationalError
from basic_memory import db
console.print(
"[yellow]Note:[/yellow] This only deletes the index database. "
"Your markdown note files will not be affected.\n"
@@ -336,15 +320,10 @@ async def _reindex(
project: str | None,
):
"""Run reindex operations."""
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import EntityRepository, ProjectRepository
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.services.search_service import SearchService
from basic_memory.services.file_service import FileService
from basic_memory.sync.sync_service import get_sync_service
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.entity_parser import EntityParser
+4 -9
View File
@@ -7,12 +7,16 @@ import uuid
from pathlib import Path
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
import typer
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
from basic_memory.schemas.base import Entity
@@ -25,12 +29,6 @@ console = Console()
async def run_doctor() -> None:
"""Run local consistency checks for file <-> database flows."""
# Deferred: the markdown parsing stack is only needed while the checks run,
# and importing it at module level slows every CLI invocation (#886).
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
@@ -142,9 +140,6 @@ def doctor(
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Run local consistency checks to verify file/database sync."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
# Doctor runs local filesystem checks — always default to local routing
@@ -1,34 +1,25 @@
"""Import command for ChatGPT conversations."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -69,9 +60,6 @@ def import_chatgpt(
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
# Create importer and run import
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers import ChatGPTImporter
importer = ChatGPTImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -66,9 +57,6 @@ def import_claude(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
importer = ClaudeConversationsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import project data from Claude.ai."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -65,9 +56,6 @@ def import_projects(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
importer = ClaudeProjectsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,34 +1,25 @@
"""Import command for basic-memory CLI to import from JSON memory format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Tuple
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -64,9 +55,6 @@ def memory_json(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
importer = MemoryJsonImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
-76
View File
@@ -1,76 +0,0 @@
"""Install the bundled man pages so `man bm` works."""
import shutil
import subprocess
from pathlib import Path
from typing import Annotated, Optional
import typer
from rich.console import Console
from basic_memory.cli.app import app
console = Console()
man_app = typer.Typer(help="Manage the bm man pages.")
app.add_typer(man_app, name="man")
# Bundled groff sources ship inside the package (src/basic_memory/man).
_MAN_SOURCE_DIR = Path(__file__).parent.parent.parent / "man"
def _default_man_root() -> Path:
# Why ~/.local/share/man: manpath(1) derives man directories from PATH
# entries on both man-db (Linux) and BSD man (macOS), so ~/.local/bin on
# PATH — the pipx/uv tool layout — makes this root searchable without any
# MANPATH configuration.
return Path.home() / ".local" / "share" / "man"
def _man_root_on_manpath(man_root: Path) -> Optional[bool]:
"""Best-effort check whether man(1) will search man_root; None if unknown."""
try:
result = subprocess.run(["manpath"], capture_output=True, text=True, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
paths = [entry.rstrip("/") for entry in result.stdout.strip().split(":") if entry]
return str(man_root).rstrip("/") in paths
@man_app.command()
def install(
directory: Annotated[
Optional[Path],
typer.Option(
"--dir",
help="Man root to install into (default: ~/.local/share/man)",
),
] = None,
) -> None:
"""Install the bm man pages, then try `man bm`."""
man_root = (directory or _default_man_root()).expanduser()
man1 = man_root / "man1"
man1.mkdir(parents=True, exist_ok=True)
pages = sorted(_MAN_SOURCE_DIR.glob("*.1"))
if not pages: # pragma: no cover - broken packaging, not a runtime state
console.print("[red]No bundled man pages found — broken installation[/red]")
raise typer.Exit(1)
for page in pages:
shutil.copyfile(page, man1 / page.name)
console.print(f"installed {man1 / page.name}")
# Trigger: the chosen root is provably absent from manpath output.
# Why: a silent install into an unsearched directory looks like success
# but `man bm` still fails; say so and hand over the one-line fix.
# Outcome: actionable hint; unknown (None) stays quiet to avoid false alarms.
if _man_root_on_manpath(man_root) is False:
console.print(
f"\n[yellow]{man_root} is not on your manpath.[/yellow] Add it with:\n"
f' export MANPATH="{man_root}:$MANPATH"'
)
console.print("\nTry: [bold]man bm[/bold]")
+1 -3
View File
@@ -5,6 +5,7 @@ from typing import Annotated, Optional
import typer
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
from rich.table import Table
@@ -49,9 +50,6 @@ def orphans(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
+3 -13
View File
@@ -20,10 +20,9 @@ from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
console = Console()
@@ -190,9 +189,6 @@ def validate(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -276,9 +272,6 @@ def infer(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -359,9 +352,6 @@ def diff(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
+2 -11
View File
@@ -5,6 +5,7 @@ import json
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
from loguru import logger
from rich.console import Console
@@ -189,16 +190,9 @@ async def run_status(
if sync_report.total == 0:
return project_item.name, sync_report
if time.monotonic() >= deadline:
# Why the hint: indexing is done by the sync coordinator, which
# only runs inside a live server (bm mcp / hosted API). In a
# CLI-only session nothing will ever drain the pending count,
# so this wait cannot succeed — point at the command that
# actually indexes (#959).
raise StatusTimeout(
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
f"to finish indexing ({sync_report.total} pending change(s) remaining). "
f"If no Basic Memory server is running, pending changes are never "
f"indexed — run 'bm reindex --project {project_item.name}' instead."
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
)
await asyncio.sleep(poll_interval)
@@ -230,9 +224,6 @@ def status(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
+12 -40
View File
@@ -14,10 +14,18 @@ from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import delete_note as mcp_delete_note
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
from basic_memory.mcp.tools import search_notes as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
tool_app = typer.Typer()
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
@@ -112,9 +120,6 @@ def write_note(
bm tool write-note --title "My Note" --folder "notes" --overwrite
bm tool write-note --title "My Note" --folder "notes" --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import write_note as mcp_write_note
try:
validate_routing_flags(local, cloud)
@@ -201,9 +206,6 @@ def read_note(
bm tool read-note my-note
bm tool read-note my-note --include-frontmatter
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import read_note as mcp_read_note
try:
validate_routing_flags(local, cloud)
@@ -270,9 +272,6 @@ def delete_note(
bm tool delete-note notes/old-draft
bm tool delete-note docs/archive --is-directory
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import delete_note as mcp_delete_note
try:
validate_routing_flags(local, cloud)
@@ -347,9 +346,6 @@ def edit_note(
bm tool edit-note my-note --operation find_replace --find-text "old" --content "new"
bm tool edit-note my-note --operation replace_section --section "## Notes" --content "updated"
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import edit_note as mcp_edit_note
try:
validate_routing_flags(local, cloud)
@@ -417,9 +413,6 @@ def build_context(
bm tool build-context memory://specs/search
bm tool build-context specs/search --depth 2 --timeframe 30d
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import build_context as mcp_build_context
try:
validate_routing_flags(local, cloud)
@@ -483,9 +476,6 @@ def recent_activity(
bm tool recent-activity --timeframe 30d --page-size 20
bm tool recent-activity --type entity --type observation
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
try:
validate_routing_flags(local, cloud)
@@ -592,9 +582,6 @@ def search_notes(
bm tool search-notes --meta status=draft
bm tool search-notes "auth" --entity-type observation --category requirement
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search
try:
validate_routing_flags(local, cloud)
@@ -700,9 +687,6 @@ def list_projects(
bm tool list-projects
bm tool list-projects --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
try:
validate_routing_flags(local, cloud)
@@ -736,9 +720,6 @@ def list_workspaces(
bm tool list-workspaces
bm tool list-workspaces --cloud
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
try:
validate_routing_flags(local, cloud)
@@ -791,9 +772,6 @@ def schema_validate(
bm tool schema-validate people/ada-lovelace.md
bm tool schema-validate --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
@@ -862,9 +840,6 @@ def schema_infer(
bm tool schema-infer meeting --threshold 0.5
bm tool schema-infer person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
@@ -921,9 +896,6 @@ def schema_diff(
bm tool schema-diff person
bm tool schema-diff person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
-1
View File
@@ -24,7 +24,6 @@ if not _version_only_invocation(sys.argv[1:]):
import_claude_conversations,
import_claude_projects,
import_memory_json,
man,
mcp,
orphans,
project,
+2 -36
View File
@@ -4,7 +4,6 @@ import importlib.util
import json
import os
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
@@ -658,26 +657,6 @@ class BasicMemoryConfig(BaseSettings):
entry = self.projects.get(project_name)
return entry.mode if entry else ProjectMode.CLOUD
def is_locally_syncable(self, project_name: str, project_path: str) -> bool:
"""Whether a project should be synced/watched on the local filesystem.
Both conditions are required (issue #949):
* The project is present in config. Config is the source of truth, so a
stale database row that was removed from config but whose deletion
has not yet been reconciled, or whose reconciliation failed must
not be synced even though it still has a real directory on disk.
* Its path is absolute. An empty or relative path resolves against the
process cwd, so syncing it would adopt whatever directory the server
was launched from as the project root and mutate unrelated files.
Cloud-only projects (empty/slug path) and cloud projects with a real
local bisync copy (absolute path) are handled correctly by these two
conditions, so no separate mode check is needed.
"""
entry = self.projects.get(project_name)
return entry is not None and Path(project_path).is_absolute()
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
"""Set the routing mode for a project.
@@ -1120,21 +1099,8 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
_secure_config_dir(file_path.parent)
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
# Trigger: long-lived readers (MCP stdio server config reload, background
# auto-update threads) re-read config.json whenever its mtime changes,
# concurrently with CLI commands saving it.
# Why: writing the destination in place truncates it first, so a concurrent
# reader can observe empty/partial JSON and load_config() exits the process.
# Outcome: write a sibling temp file (unique per process/thread so parallel
# savers cannot interleave) and publish atomically via os.replace — readers
# always see either the old or the new complete document. (#940)
tmp_path = file_path.parent / f"{file_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
tmp_path.write_text(json.dumps(config_dict, indent=2))
_secure_config_file(tmp_path)
os.replace(tmp_path, file_path)
finally:
tmp_path.unlink(missing_ok=True)
file_path.write_text(json.dumps(config_dict, indent=2))
_secure_config_file(file_path)
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+13 -25
View File
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import (
AsyncEngine,
async_scoped_session,
)
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
from sqlalchemy.pool import NullPool
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
@@ -216,31 +216,19 @@ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
"isolation_level": None, # Use autocommit mode
}
)
if db_type == DatabaseType.MEMORY:
# Trigger: an in-memory SQLite URL would default to StaticPool, which hands the
# same DBAPI connection to every concurrently checked-out session.
# Why: concurrent asyncio tasks then share one transaction scope — a rollback
# issued by one session (scoped_session exception handling or the pool's
# reset-on-return) silently destroys another session's uncommitted writes (#940).
# Outcome: a single-connection blocking queue pool keeps the in-memory database
# alive for the engine's lifetime while serializing sessions at transaction
# granularity, restoring the isolation the repositories assume.
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=AsyncAdaptedQueuePool,
pool_size=1,
max_overflow=0,
)
elif os.name == "nt":
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=NullPool, # Disable connection pooling on Windows
echo=False,
)
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
# between connections
if db_type == DatabaseType.FILESYSTEM:
engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=NullPool, # Disable connection pooling on Windows
echo=False,
)
else:
# In-memory databases need connection pooling to maintain state
engine = create_async_engine(db_url, connect_args=connect_args)
else:
engine = create_async_engine(db_url, connect_args=connect_args)
+22 -68
View File
@@ -302,76 +302,26 @@ async def format_file(
return None
# A frontmatter fence is a line containing exactly `---`, optionally followed by
# trailing horizontal whitespace. Anchoring to a full line (rather than a bare
# substring/`startswith`) prevents single-line content like
# `---\nstatus: active\n---\nBody` — where `\n` is a literal backslash-n, not a
# newline — from being misread as frontmatter. See issue #972.
_FENCE_RE = re.compile(r"^---[ \t]*$")
def _split_frontmatter(content: str) -> Optional[tuple[str, str]]:
"""Split content into (yaml_block, body) when it opens with a line-anchored fence.
The opening fence must be the very first line and the closing fence must be a
later line, each matching exactly `---` (with optional trailing whitespace).
Returns:
A `(yaml_block, body)` tuple when a complete fenced block is present, or
``None`` when the content does not open with a frontmatter fence.
Raises:
ParseError: If the content opens with a fence but has no closing fence.
"""
lines = content.splitlines(keepends=True)
# Skip leading blank lines: a document may begin with whitespace before the
# opening fence (e.g. a heredoc/dedented string starting with a newline). This
# does NOT relax line-anchoring — the opening fence must still be the first
# non-blank line, all on its own, so a single-line `---\\nstatus...` (literal
# backslash-n) is still rejected. See issue #972.
start = 0
while start < len(lines) and lines[start].strip() == "":
start += 1
if start >= len(lines) or not _FENCE_RE.match(lines[start].rstrip("\r\n")):
return None
# Find the closing fence on its own line somewhere after the opening fence.
for index in range(start + 1, len(lines)):
if _FENCE_RE.match(lines[index].rstrip("\r\n")):
yaml_block = "".join(lines[start + 1 : index])
body = "".join(lines[index + 1 :])
return yaml_block, body
raise ParseError("Invalid frontmatter format")
def has_frontmatter(content: str) -> bool:
"""
Check if content contains valid YAML frontmatter.
Frontmatter requires `---` fences on their own lines; an inline `---` (such as
a single-line string that merely starts with the characters `---`) is not
frontmatter.
Args:
content: Content to check
Returns:
True if content has line-anchored frontmatter fences, False otherwise
True if content has valid frontmatter markers (---), False otherwise
"""
if not content:
return False
# Strip BOM before checking for frontmatter markers
content = strip_bom(content)
try:
return _split_frontmatter(content) is not None
except ParseError:
# An opening fence with no closing fence is not usable frontmatter.
content = strip_bom(content).strip()
if not content.startswith("---"):
return False
return "---" in content[3:]
def parse_frontmatter(content: str) -> Dict[str, Any]:
"""
@@ -389,14 +339,17 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
try:
# Strip BOM before parsing frontmatter
content = strip_bom(content)
split = _split_frontmatter(content)
if split is None:
if not content.strip().startswith("---"):
raise ParseError("Content has no frontmatter")
yaml_block, _ = split
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
# Parse YAML
try:
frontmatter = yaml.safe_load(yaml_block)
frontmatter = yaml.safe_load(parts[1])
# Handle empty frontmatter (None from yaml.safe_load)
if frontmatter is None:
return {}
@@ -428,17 +381,18 @@ def remove_frontmatter(content: str) -> str:
ParseError: If content starts with frontmatter marker but is malformed
"""
# Strip BOM before processing
content = strip_bom(content)
content = strip_bom(content).strip()
split = _split_frontmatter(content)
# Trigger: content does not open with a line-anchored fence
# Why: inline `---` is ordinary content, not frontmatter (issue #972)
# Outcome: return the content untouched (stripped to preserve prior behavior)
if split is None:
return content.strip()
# Return as-is if no frontmatter marker
if not content.startswith("---"):
return content
_, body = split
return body.strip()
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
return parts[2].strip()
def dump_frontmatter(post: frontmatter.Post) -> str:
-9
View File
@@ -7,15 +7,6 @@ from typing import Set
from basic_memory.config import resolve_data_dir
# Marker shared by the API ignored-path rejection detail and MCP-side error handling.
# The sync-file endpoint embeds it in its 400 detail and edit_note's disk recovery
# matches on it, so "exists but ignored" stays distinguishable from generic rejections
# without duplicating message text across layers.
IGNORED_PATH_REJECTION_DETAIL = (
"matches Basic Memory ignore rules (.bmignore or project .gitignore)"
)
# Common directories and patterns to ignore by default
# These are used as fallback if .bmignore doesn't exist
DEFAULT_IGNORE_PATTERNS = {
-1
View File
@@ -1 +0,0 @@
.so man1/bm.1
-134
View File
@@ -1,134 +0,0 @@
.TH BM 1 "2026-06-11" "basic-memory" "Basic Memory Manual"
.SH NAME
bm \- local-first knowledge base for humans and AI agents
.SH SYNOPSIS
.B bm
.I COMMAND
.RI [ ARGS ]...
.br
.B basic-memory
.I COMMAND
.RI [ ARGS ]...
.SH DESCRIPTION
.B bm
manages Basic Memory projects: plain markdown files that form a knowledge
graph. Files are the source of truth; SQLite provides indexing and
full-text search; the same operations are exposed to AI agents over the
Model Context Protocol (MCP) and to humans and scripts through this CLI.
.PP
Notes use semantic markdown: observations
.RB ( "\- [category] text #tag" )
and relations
.RB ( "\- relation_type [[Target]]" )
become queryable graph structure. Projects route independently to the
local API or to Basic Memory Cloud.
.SH COMMANDS
Knowledge operations:
.TP
.B bm tool
CLI access to the MCP tools (write-note, read-note, search-notes,
build-context, ...). These emit JSON and are the scriptable surface.
.TP
.B bm status
Show sync status between files and the database.
.TP
.B bm reindex
Index local file changes and rebuild search/embeddings. This is the manual
sync trigger when no MCP server is running.
.TP
.B bm doctor
Run end-to-end file/database consistency checks.
.TP
.B bm orphans
List entities with no relations in the knowledge graph.
.TP
.B bm format
Run configured formatters over note files.
.PP
Projects and schemas:
.TP
.B bm project
Add, remove, list projects; set the default; flip a project between local
and cloud routing.
.TP
.B bm schema
List, validate, infer, and drift-check Picoschema note-type contracts.
.PP
Data and cloud:
.TP
.B bm import
Import from ChatGPT, Claude, or memory.json exports. Imports write files;
run
.B bm reindex
afterwards.
.TP
.B bm cloud
Authenticate, sync (push/pull/sync/bisync), snapshots, and team workspace
administration.
.PP
Infrastructure:
.TP
.B bm mcp
Run the MCP server (hosts the live file watcher).
.TP
.B bm man
Manage these man pages
.RB ( "bm man install" ).
.TP
.B bm reset
Drop and recreate the database (destructive).
.PP
Most commands accept
.B \-\-project
.I NAME
to target a project and
.BR \-\-local / \-\-cloud
to override routing.
.SH EXAMPLES
Write and find a note from the shell:
.PP
.nf
.RS
echo "# Standup notes" | bm tool write-note \\
\-\-title "Standup" \-\-folder notes
bm tool search-notes "standup"
.RE
.fi
.PP
Pick up files created outside the tools:
.PP
.nf
.RS
bm status # shows pending changes
bm reindex # indexes them
.RE
.fi
.SH FILES
.TP
.I ~/.basic-memory/config.json
Projects, default project, per-project routing modes, cloud settings.
.TP
.I ~/.basic-memory/memory.db
SQLite index (derived; safe to rebuild with bm reindex).
.SH ENVIRONMENT
.TP
.B BASIC_MEMORY_FORCE_LOCAL
Force local routing regardless of cloud mode.
.TP
.B BASIC_MEMORY_LOG_LEVEL
Logging verbosity (e.g. DEBUG).
.SH SEE ALSO
Full manual (machine-readable, agent-traversable): the
.I manual
Basic Memory project \(em see docs/manual-pages.md in the repository.
Documentation: https://docs.basicmemory.com
.PP
For AI agents: the complete tool reference lives in the manual project as
section\-3 pages (write-note(3), search-notes(3), ...), queryable via
.B search_notes
with
.BR "metadata_filters={\(dqtype\(dq: \(dqmanpage\(dq}" .
.SH BUGS
https://github.com/basicmachines-co/basic-memory/issues
.SH AUTHORS
Basic Machines (https://basicmachines.co)
+13 -38
View File
@@ -5,16 +5,14 @@ from dataclasses import dataclass
from threading import RLock
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
import logfire
from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials
from basic_memory.config import ConfigManager, ProjectMode
if TYPE_CHECKING:
# FastAPI is only needed when a request routes through the local ASGI
# transport; importing it at module level costs ~0.1s on every CLI start (#886).
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
@@ -30,8 +28,8 @@ class _PreparedLocalAsgiDatabase:
_prepared_local_asgi_database_lock = RLock()
_prepared_local_asgi_database_prepare_locks: dict["FastAPI", Lock] = {}
_prepared_local_asgi_databases: dict["FastAPI", _PreparedLocalAsgiDatabase] = {}
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
def _force_local_mode() -> bool:
@@ -59,7 +57,7 @@ def _build_timeout() -> Timeout:
)
def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient:
def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client for an already-prepared FastAPI app."""
from basic_memory.workspace_context import workspace_permalink_headers
@@ -73,7 +71,7 @@ def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient:
)
def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock:
def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
"""Get the async lock that serializes first-time DB preparation for an app."""
with _prepared_local_asgi_database_lock:
prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app)
@@ -84,10 +82,8 @@ def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock:
@asynccontextmanager
async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDatabaseState]:
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
"""Resolve database state for a local ASGI request."""
# Imported on first local-ASGI use so CLI startup never pays for FastAPI (#886).
from fastapi import Depends, Request
from fastapi.dependencies.utils import get_dependant, solve_dependencies
from basic_memory.deps import get_engine_factory
@@ -131,7 +127,7 @@ async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDat
yield await resolve_database_state(**solved.values)
def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool:
def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
"""Retain an active local ASGI database preparation if one exists."""
with _prepared_local_asgi_database_lock:
active = _prepared_local_asgi_databases.get(app)
@@ -143,7 +139,7 @@ def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool:
def _install_prepared_local_asgi_database(
app: "FastAPI",
app: FastAPI,
database_state: LocalDatabaseState,
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
) -> None:
@@ -167,7 +163,7 @@ def _install_prepared_local_asgi_database(
)
def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_value: object) -> None:
def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None:
"""Restore a FastAPI app.state attribute captured before local ASGI preparation."""
if previous_value is _MISSING_STATE_VALUE:
if hasattr(app.state, name):
@@ -177,7 +173,7 @@ def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_valu
def _release_prepared_local_asgi_database(
app: "FastAPI",
app: FastAPI,
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
"""Release local ASGI database state after a client context exits."""
with _prepared_local_asgi_database_lock:
@@ -200,7 +196,7 @@ def _release_prepared_local_asgi_database(
@asynccontextmanager
async def _prepared_local_asgi_database(app: "FastAPI") -> AsyncIterator[None]:
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
"""Initialize local ASGI database state before the first request."""
prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app)
async with prepare_lock:
@@ -372,8 +368,7 @@ async def get_client(
1. Factory injection.
2. Explicit routing flags (--local/--cloud).
3. Per-project mode routing when project_name is provided.
4. Cloud routing when a workspace selector is provided.
5. Local ASGI transport by default.
4. Local ASGI transport by default.
"""
if _client_factory:
async with _client_factory(workspace=workspace) as client:
@@ -433,26 +428,6 @@ async def get_client(
yield client
return
# --- Workspace-selector routing ---
# Trigger: caller passed a cloud workspace selector and nothing above routed.
# Why: a workspace names a cloud tenant — silently serving the request from
# the local ASGI app sent writes to the wrong destination (#954: a cloud
# project create either failed on the cloud-style path or landed locally).
# Outcome: route to the cloud proxy when credentials exist; without
# credentials fail fast instead of pretending the operation succeeded.
if workspace is not None:
if not has_cloud_credentials(config):
raise RuntimeError(
f"A cloud workspace was requested ('{workspace}') but no cloud "
"credentials were found. Run 'bm cloud login' or "
"'bm cloud set-key <key>' first, or omit the workspace selector "
"for a local operation."
)
logger.debug(f"Workspace selector '{workspace}' provided - using cloud proxy client")
async with _cloud_client(config, timeout, workspace=workspace) as client:
yield client
return
# --- Default fallback ---
logger.debug("Default routing - using ASGI client for local Basic Memory API")
async with _asgi_client(timeout) as client:
+1 -5
View File
@@ -7,9 +7,7 @@ from typing import Optional, Any
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
class DirectoryClient:
@@ -57,8 +55,6 @@ class DirectoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"dir_name": dir_name,
"depth": depth,
+1 -55
View File
@@ -8,10 +8,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
from basic_memory.schemas.response import (
EntityResponse,
DeleteEntitiesResponse,
@@ -60,8 +57,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.create_entity",
client_name="knowledge",
@@ -94,8 +89,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.update_entity",
client_name="knowledge",
@@ -123,8 +116,6 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_entity",
client_name="knowledge",
@@ -156,8 +147,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
with logfire.span(
"mcp.client.knowledge.patch_entity",
client_name="knowledge",
@@ -185,8 +174,6 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_delete
with logfire.span(
"mcp.client.knowledge.delete_entity",
client_name="knowledge",
@@ -214,8 +201,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.move_entity",
client_name="knowledge",
@@ -246,8 +231,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.move_directory",
client_name="knowledge",
@@ -278,8 +261,6 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.delete_directory",
client_name="knowledge",
@@ -295,43 +276,10 @@ class KnowledgeClient:
)
return DirectoryDeleteResult.model_validate(response.json())
# --- Single-file sync ---
async def sync_file(self, file_path: str) -> EntityResponse:
"""Index a markdown file that exists on disk but is not indexed yet.
Args:
file_path: Markdown file path relative to the project root
Returns:
EntityResponse for the indexed entity
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",
operation="sync_file",
):
response = await call_post(
self.http_client,
f"{self._base_path}/sync-file",
json={"file_path": file_path},
client_name="knowledge",
operation="sync_file",
path_template="/v2/projects/{project_id}/knowledge/sync-file",
)
return EntityResponse.model_validate(response.json())
# --- Orphan detection ---
async def get_orphans(self) -> list[GraphNode]:
"""Get entities that have no incoming or outgoing relations."""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_orphans",
client_name="knowledge",
@@ -361,8 +309,6 @@ class KnowledgeClient:
Raises:
ToolError: If the identifier cannot be resolved
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.resolve_entity",
client_name="knowledge",
+1 -8
View File
@@ -8,10 +8,7 @@ from typing import Optional
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import GraphContext
@@ -66,8 +63,6 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"depth": depth,
"page": page,
@@ -118,8 +113,6 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"timeframe": timeframe,
"depth": depth,
+7 -21
View File
@@ -7,9 +7,13 @@ from typing import Any
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import (
call_delete,
call_get,
call_patch,
call_post,
call_put,
)
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.schemas.v2 import ProjectResolveResponse
@@ -49,8 +53,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
"/v2/projects/",
@@ -69,8 +71,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/",
@@ -93,8 +93,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_delete
url = f"/v2/projects/{project_external_id}"
if delete_notes:
url += "?delete_notes=true"
@@ -116,8 +114,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/resolve",
@@ -137,8 +133,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
response = await call_put(
self.http_client,
f"/v2/projects/{project_external_id}/default",
@@ -160,8 +154,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
response = await call_patch(
self.http_client,
f"/v2/projects/{project_external_id}",
@@ -189,8 +181,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
url = f"/v2/projects/{project_external_id}/sync"
params = []
if force_full:
@@ -214,8 +204,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"/v2/projects/{project_external_id}/status",
@@ -234,8 +222,6 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"/v2/projects/{project_external_id}/info",
+1 -5
View File
@@ -6,9 +6,7 @@ Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
from httpx import AsyncClient, Response
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_get
class ResourceClient:
@@ -51,8 +49,6 @@ class ResourceClient:
Raises:
ToolError: If the resource is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.resource.read",
client_name="resource",
+1 -9
View File
@@ -5,9 +5,7 @@ Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
from httpx import AsyncClient
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.schemas.schema import (
ValidationReport,
InferenceReport,
@@ -58,8 +56,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
params: dict[str, str] = {}
if note_type:
params["note_type"] = note_type
@@ -91,8 +87,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"{self._base_path}/infer",
@@ -112,8 +106,6 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"{self._base_path}/diff/{note_type}",
+1 -6
View File
@@ -8,10 +8,7 @@ from typing import Any
from httpx import AsyncClient
import logfire
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.search import SearchResponse
@@ -60,8 +57,6 @@ class SearchClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.search.search",
client_name="search",
+23 -164
View File
@@ -8,24 +8,10 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
# PEP 563 lazy annotations keep `Context` usable in signatures without importing
# fastmcp at module load — the fastmcp/mcp stack costs ~0.5s of CLI startup (#886).
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, nullcontext
from typing import (
TYPE_CHECKING,
AsyncIterator,
Awaitable,
Callable,
List,
Optional,
Sequence,
Tuple,
cast,
)
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
from uuid import UUID
from httpx import AsyncClient
@@ -33,6 +19,8 @@ from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
@@ -58,9 +46,6 @@ from basic_memory.workspace_context import (
workspace_permalink_context,
)
if TYPE_CHECKING:
from fastmcp import Context
# --- Workspace provider injection ---
# Mirrors the set_client_factory() pattern in async_client.py.
# The cloud MCP server sets a provider that queries its own database directly,
@@ -69,14 +54,6 @@ _workspace_provider: Optional[Callable[[], Awaitable[list[WorkspaceInfo]]]] = No
_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
class WorkspaceProjectLookupMiss(ValueError):
"""A project was absent from the workspace index (as opposed to ambiguous).
Misses are retried once against a freshly rebuilt index, because the
session cache may simply predate an out-of-band project creation (#956).
"""
@dataclass(frozen=True)
class WorkspaceProjectEntry:
"""A cloud project resolved together with the workspace that owns it."""
@@ -483,17 +460,6 @@ def _canonical_memory_path_for_workspace(
# Outcome: lookups preserve the complete workspace/project canonical permalink.
if not normalized_remainder:
normalized_remainder = project_permalink
# Same index-form rule as _canonical_memory_path_for_active_route (#957):
# without an active workspace permalink context, stored permalinks are
# project-qualified and a workspace-prefixed pattern cannot match.
if "*" in normalized_remainder and current_workspace_permalink_context() is None:
return build_qualified_permalink_reference(
project_permalink,
normalized_remainder,
include_project=True,
)
return build_qualified_permalink_reference(
project_permalink,
normalized_remainder,
@@ -511,24 +477,6 @@ def _canonical_memory_path_for_active_route(
) -> str:
"""Return the canonical permalink path for the currently routed project/workspace."""
project_prefix = active_project.permalink
# Trigger: the path contains a glob wildcard (folder/*) and no server-side
# workspace permalink context is active.
# Why: patterns match raw against the search index, so they must mirror the
# stored permalink form. The contextvar is what qualified permalinks at
# write time — when it is absent, stored rows are project-qualified and a
# workspace prefix (from the client's cached_workspace display state)
# guarantees zero matches (#957). Direct lookups keep full qualification
# because the link resolver understands it; patterns have no fallback.
# Outcome: without the contextvar, qualify patterns with the project prefix
# only; with it, fall through to normal workspace canonicalization.
if "*" in path and current_workspace_permalink_context() is None:
if not include_project:
return path
if path == project_prefix or path.startswith(f"{project_prefix}/"):
return path
return f"{project_prefix}/{path}"
workspace_remainder = path
if include_project and (path == project_prefix or path.startswith(f"{project_prefix}/")):
# Trigger: the memory URL already names the active project root/prefix
@@ -774,15 +722,9 @@ async def _fetch_workspace_project_entries(
async def _ensure_workspace_project_index(
context: Optional[Context] = None,
*,
force_refresh: bool = False,
) -> WorkspaceProjectIndex:
"""Build or load the session-local workspace/project lookup index.
force_refresh bypasses the cached index and rebuilds from discovery
used by resolve_workspace_project_identifier when a lookup misses (#956).
"""
if context and not force_refresh:
"""Build or load the session-local workspace/project lookup index."""
if context:
cached_raw = await context.get_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY)
cached_index = _workspace_project_index_from_state(cached_raw)
if cached_index is not None:
@@ -856,99 +798,13 @@ async def ensure_workspace_project_index(
return await _ensure_workspace_project_index(context=context)
def _match_workspace_identifier(
workspaces: tuple[WorkspaceInfo, ...],
workspace_identifier: str,
) -> WorkspaceInfo:
"""Resolve the first segment of a qualified route to a single workspace.
The edit_note/write_note contract advertises that the workspace segment may be a
slug, tenant_id, or display name. We honor those forms in a fixed priority order so
that adding tenant_id/name support never changes the meaning of an identifier that
already resolves today:
1. slug (casefold) existing behavior, checked first so working routes are stable.
2. tenant_id exact match against the opaque id (no casefolding, mirroring the
precedent in ``workspace_matches_exact_identifier``).
3. display name (casefold) names are not guaranteed unique, so a name that matches
multiple workspaces is rejected rather than silently picking one.
"""
# Trigger: identifier equals a workspace slug (casefold).
# Why: slug is the canonical routing key; resolving it first guarantees a workspace
# whose display name collides with another workspace's slug yields to the slug owner.
# Outcome: return the slug owner before tenant_id/name are considered.
slug_matches = [
workspace
for workspace in workspaces
if workspace.slug.casefold() == workspace_identifier.casefold()
]
if slug_matches:
return slug_matches[0]
# Trigger: identifier exactly equals a workspace tenant_id (an opaque id).
# Why: tenant_ids are unique, so an exact hit is unambiguous and needs no tie-break.
tenant_matches = [
workspace for workspace in workspaces if workspace.tenant_id == workspace_identifier
]
if tenant_matches:
return tenant_matches[0]
# Trigger: identifier matches one or more workspace display names (casefold).
# Why: names are not guaranteed unique; failing fast on collisions keeps routing
# deterministic and tells the caller exactly how to disambiguate.
name_matches = [
workspace
for workspace in workspaces
if workspace.name.casefold() == workspace_identifier.casefold()
]
if len(name_matches) > 1:
candidates = ", ".join(workspace.slug for workspace in name_matches)
raise ValueError(
f"Workspace name '{workspace_identifier}' matched multiple workspaces "
f"(slugs: {candidates}). Use the workspace slug or tenant_id to disambiguate."
)
if name_matches:
return name_matches[0]
available = ", ".join(workspace.slug for workspace in workspaces)
raise ValueError(
f"Workspace '{workspace_identifier}' was not found by slug, tenant_id, or name. "
f"Available workspace slugs: {available}"
)
async def resolve_workspace_project_identifier(
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve a project by external_id (UUID), qualified name, or unqualified name."""
index = await _ensure_workspace_project_index(context=context)
try:
return await _resolve_workspace_project_from_index(index, project, context)
except WorkspaceProjectLookupMiss:
# Trigger: the lookup missed the session-cached index.
# Why: a miss is exactly the signal the cache may be stale — projects
# created out-of-band (CLI, a teammate in a shared workspace) post-date
# the index built at session start (#956).
# Outcome: rebuild the index once and retry; a second miss is authoritative
# and its error (with the refreshed project list) propagates.
logger.info(
f"Workspace project lookup missed for '{project}'; refreshing index and retrying"
)
refreshed = await _ensure_workspace_project_index(context=context, force_refresh=True)
return await _resolve_workspace_project_from_index(refreshed, project, context)
async def _resolve_workspace_project_from_index(
index: WorkspaceProjectIndex,
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve a project against one concrete index snapshot.
Raises WorkspaceProjectLookupMiss for absent projects (retryable via index
refresh) and plain ValueError for ambiguity, which a refresh cannot fix.
"""
# Fast path: direct lookup by external_id when the identifier is a UUID
# Canonicalize via str(UUID(...)) so uppercase, brace-wrapped, or urn:uuid forms
# all hash to the same lowercase-hyphenated key as the stored external_ids.
@@ -960,14 +816,23 @@ async def _resolve_workspace_project_from_index(
except ValueError:
pass
workspace_identifier, project_identifier = _split_qualified_project_identifier(project)
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
project_permalink = generate_permalink(project_identifier)
if workspace_identifier:
# Honor the documented "slug, name, or tenant_id" contract for the workspace
# segment; _match_workspace_identifier raises a clear error on ambiguous names
# and unknown identifiers, listing what forms were tried.
workspace = _match_workspace_identifier(index.workspaces, workspace_identifier)
if workspace_slug:
workspace_matches = [
workspace
for workspace in index.workspaces
if workspace.slug.casefold() == workspace_slug.casefold()
]
if not workspace_matches:
available = ", ".join(workspace.slug for workspace in index.workspaces)
raise ValueError(
f"Workspace '{workspace_slug}' was not found. "
f"Available workspace slugs: {available}"
)
workspace = workspace_matches[0]
matches = [
entry
for entry in index.entries_by_permalink.get(project_permalink, ())
@@ -978,7 +843,7 @@ async def _resolve_workspace_project_from_index(
failed_workspace.tenant_id == workspace.tenant_id
for failed_workspace in index.failed_workspaces
):
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
"could not be loaded. Retry after workspace discovery recovers."
)
@@ -987,7 +852,7 @@ async def _resolve_workspace_project_from_index(
for entry in index.entries
if entry.workspace.tenant_id == workspace.tenant_id
)
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Project '{project_identifier}' was not found in workspace "
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
)
@@ -1012,7 +877,7 @@ async def _resolve_workspace_project_from_index(
"retry or use a qualified project from an indexed workspace."
)
available = ", ".join(entry.qualified_name for entry in index.entries)
raise WorkspaceProjectLookupMiss(
raise ValueError(
f"Project '{project}' was not found in indexed cloud workspaces. "
f"Available projects: {available}.{failed_note}"
)
@@ -1347,9 +1212,6 @@ async def resolve_project_and_path(
# Why: allow project-scoped memory URLs without requiring a separate project parameter
# Outcome: attempt to resolve the prefix as a project and route to it
if project_prefix:
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
if cached_project and _project_matches_identifier(cached_project, project_prefix):
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
@@ -1576,9 +1438,6 @@ async def get_project_client(
is_factory_mode,
)
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# When project_id (UUID) is provided, prefer it as the resolution identifier.
# external_id is unambiguous across workspaces; project name can collide.
project_identifier = project_id if project_id else project
@@ -1,17 +0,0 @@
"""Shared loader for the bundled cloud-discovery markdown resources."""
from pathlib import Path
def load_discovery_resource(filename: str) -> str:
"""Read a bundled discovery markdown file with promo placeholders rendered.
The markdown carries a {{OSS_DISCOUNT_CODE}} placeholder so the promo code
has one source of truth (cli.promo); substitute before it reaches users.
"""
# Import here to avoid pulling CLI promo machinery (analytics, rich, config)
# into the MCP server import graph at module load.
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
content = (Path(__file__).parent / filename).read_text(encoding="utf-8")
return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE)
+1 -6
View File
@@ -54,10 +54,7 @@ def _format_entity_block(result: ContextResult) -> str:
lines.append("")
lines.append("### Relations")
for rel in relation_items:
# Unresolved forward references have no resolved entity yet; fall back
# to the literal target text instead of rendering [[None]] (#955)
target = rel.to_entity or rel.to_name
lines.append(f"- {rel.relation_type} [[{target}]]")
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
# --- Related entities (non-relation related results) ---
related_entities: list[EntitySummary | ObservationSummary] = [
@@ -115,7 +112,6 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
@mcp.tool(
title="Build Context",
description="""Build context from a memory:// URI to continue conversations naturally.
Use this to follow up on previous discussions or explore related topics.
@@ -135,7 +131,6 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
- "json" (default): Structured JSON with internal fields excluded
- "text": Compact markdown text for LLM consumption
""",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
-2
View File
@@ -17,9 +17,7 @@ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
@mcp.tool(
title="Create Canvas",
description="Create an Obsidian canvas file to visualize concepts and connections.",
tags={"canvas", "notes"},
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
)
async def canvas(
@@ -105,9 +105,7 @@ def _format_document_for_chatgpt(
@mcp.tool(
title="Search Knowledge Base",
description="Search for content across the knowledge base",
tags={"search"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search(
@@ -169,9 +167,7 @@ async def search(
@mcp.tool(
title="Fetch Document",
description="Fetch the full contents of a search result document",
tags={"search", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def fetch(
+4 -4
View File
@@ -1,15 +1,15 @@
"""Cloud information MCP tool."""
from basic_memory.mcp.resources.discovery import load_discovery_resource
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool(
"cloud_info",
title="Cloud Info",
tags={"cloud"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def cloud_info() -> str:
"""Return optional Basic Memory Cloud information and setup guidance."""
return load_discovery_resource("cloud_info.md")
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
return content_path.read_text(encoding="utf-8")
@@ -182,9 +182,7 @@ def _directory_path_for_delete(
@mcp.tool(
title="Delete Note",
description="Delete a note or directory by title, permalink, or path",
tags={"notes"},
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_note(
+8 -106
View File
@@ -1,19 +1,13 @@
"""Edit note tool for Basic Memory MCP server."""
from typing import TYPE_CHECKING, Annotated, Optional, Literal
from typing import Annotated, Optional, Literal
import logfire
from httpx import HTTPStatusError
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
if TYPE_CHECKING: # pragma: no cover
from basic_memory.mcp.clients import KnowledgeClient
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import IGNORED_PATH_REJECTION_DETAIL
from basic_memory.mcp.project_context import (
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
@@ -22,7 +16,6 @@ from basic_memory.mcp.project_context import (
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import _extract_response_data, _response_detail_text
from basic_memory.schemas.base import Entity
from basic_memory.schemas.response import EntityResponse
from basic_memory.services.link_resolver import (
@@ -59,79 +52,6 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]
return title, directory
# Suffixes mimetypes maps to text/markdown (extension matching is case-insensitive),
# mirroring FileService.is_markdown which gates the sync-file endpoint server-side.
_MARKDOWN_SUFFIXES = (".md", ".markdown")
async def _resolve_after_disk_recovery(
knowledge_client: "KnowledgeClient",
identifier: str,
) -> Optional[str]:
"""Recover from a resolution miss when the note exists on disk but is not indexed.
Trigger: identifier resolution failed with "not found", but the identifier may map
to a markdown file written directly to disk before the watcher indexed it (#581).
Why: editing an on-disk note should not require a manual full sync or watcher restart.
Outcome: the single file is indexed server-side and resolution is retried exactly
once. Returns None when the identifier does not map to an indexable file on
disk, so the caller keeps its existing not-found handling.
"""
# Try the identifier as-is first so existing .markdown/.MD files are found; only
# fall back to appending markdown suffixes (".md" first, then ".markdown") when
# the identifier does not already carry one, so 'notes/foo.markdown' never becomes
# 'notes/foo.markdown.md' and a stem identifier still reaches 'notes/foo.markdown'.
candidates = [identifier]
if not identifier.lower().endswith(_MARKDOWN_SUFFIXES):
candidates.extend(f"{identifier}{suffix}" for suffix in _MARKDOWN_SUFFIXES)
for candidate in candidates:
try:
synced = await knowledge_client.sync_file(candidate)
except ToolError as sync_error:
# Trigger: the sync-file request failed
# Why: 400/404 are the expected "nothing to recover" rejections (missing
# file, traversal, non-markdown) — except the ignored-path 400, which
# means the file exists on disk but the ignore rules forbid indexing
# it, so falling through to auto-create would silently shadow the
# file. Anything else — auth, server, transport-level failures — is a
# real error that must not be masked as a not-found miss.
# Outcome: ignored-path rejections raise a clear ToolError; other expected
# rejections try the next candidate or fall through to the caller's
# existing not-found behavior; unexpected failures propagate.
cause = sync_error.__cause__
candidate_rejected = isinstance(
cause, HTTPStatusError
) and cause.response.status_code in (400, 404)
if not candidate_rejected:
raise
detail = _response_detail_text(_extract_response_data(cause.response)) or ""
if IGNORED_PATH_REJECTION_DETAIL in detail:
raise ToolError(
f"Note file '{candidate}' exists on disk but {IGNORED_PATH_REJECTION_DETAIL} "
"and will not be edited"
) from sync_error
logger.debug(f"edit_note disk recovery skipped for '{candidate}': {sync_error}")
continue
# Trigger: sync-file succeeded and returned the indexed entity.
# Why: the server may have canonicalized the path casing (notes/Disk-Note ->
# notes/disk-note.md), so strictly re-resolving the raw identifier can
# still miss the entity we just indexed.
# Outcome: use the entity identity from the sync-file response directly; only
# fall back to a strict re-resolve when an older server omits external_id,
# and let that re-resolve fail loudly instead of guessing.
if synced.external_id:
logger.info(
f"edit_note indexed unindexed file '{candidate}' as entity {synced.external_id}"
)
return synced.external_id
logger.info(f"edit_note indexed unindexed file '{candidate}'; retrying resolution")
return await knowledge_client.resolve_entity(identifier, strict=True)
return None
def _compose_workspace_project_route(
*,
workspace: Optional[str],
@@ -206,8 +126,7 @@ The note with identifier '{identifier}' could not be found. The `find_replace` a
## Suggestions to try:
1. **Use append/prepend instead**: These operations will create the note automatically if it doesn't exist
2. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
3. **File exists on disk but is not indexed yet?**: edit_note indexes the file automatically when the identifier matches its path (e.g. 'folder/note' for 'folder/note.md'). If your identifier is a title or differs from the file path, run a sync (`basic-memory sync`) or wait for the file watcher, then retry
4. **Try different exact identifier formats**:
3. **Try different exact identifier formats**:
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
- If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
- Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
@@ -304,9 +223,7 @@ Error editing note '{identifier}': {error_message}
@mcp.tool(
title="Edit Note",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
tags={"notes"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
@@ -426,9 +343,7 @@ async def edit_note(
Note:
Edit operations require exact identifier matches. If unsure, use read_note() or
search_notes() first to find the correct identifier. When the identifier looks
like a file path and the file exists on disk but is not indexed yet, edit_note
indexes that file automatically and retries the edit. The tool provides detailed
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
# Resolve effective default: allow MCP clients to send null for optional int field
@@ -550,27 +465,14 @@ async def edit_note(
strict=True,
)
except Exception as resolve_error:
# Trigger: entity does not exist yet
# Why: append/prepend can meaningfully create a new note from the content,
# while find_replace/replace_section require existing content to modify
# Outcome: note is created via the same path as write_note
error_msg = str(resolve_error).lower()
is_not_found = "entity not found" in error_msg or "not found" in error_msg
# Trigger: resolution missed but the file may already exist on disk
# Why: files written directly to disk are invisible to identifier
# resolution until indexed; editing them should just work (#581)
# Outcome: the single file is indexed and resolution retried once
recovered_entity_id: str | None = None
if is_not_found:
recovered_entity_id = await _resolve_after_disk_recovery(
knowledge_client, entity_identifier
)
if recovered_entity_id is not None:
entity_id = recovered_entity_id
elif is_not_found and operation in ("append", "prepend"):
# Trigger: entity does not exist yet (on disk or in the index)
# Why: append/prepend can meaningfully create a new note from the
# content, while find_replace/replace_section require existing
# content to modify
# Outcome: note is created via the same path as write_note
if is_not_found and operation in ("append", "prepend"):
title, directory = _parse_identifier_to_title_and_directory(identifier)
# Validate directory path (same security check as write_note)
@@ -11,9 +11,7 @@ from basic_memory.mcp.server import mcp
@mcp.tool(
title="List Directory",
description="List directory contents with filtering and depth control.",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_directory(
-2
View File
@@ -357,9 +357,7 @@ delete_note("{identifier}")
@mcp.tool(
title="Move Note",
description="Move a note or directory to a new location, updating database and maintaining links.",
tags={"notes"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def move_note(
@@ -357,8 +357,6 @@ def _format_project_list_json(
@mcp.tool(
"list_memory_projects",
title="List Memory Projects",
tags={"projects"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_memory_projects(
@@ -477,14 +475,10 @@ async def _resolve_workspace_routing(
if workspace is None:
return None
forced_local = _explicit_routing() and _force_local_mode()
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
config = ConfigManager().config
# Resolve whenever credentials make workspace discovery possible — not only
# under explicit --cloud. A workspace selector implies cloud routing
# (get_client routes it to the cloud proxy, #954), and the transport needs
# the tenant id in X-Workspace-ID, not a slug or display name.
should_resolve_workspace = is_factory_mode() or (
has_cloud_credentials(config) and not forced_local
explicit_cloud_routing and has_cloud_credentials(config)
)
if not should_resolve_workspace:
return workspace
@@ -501,8 +495,6 @@ async def _resolve_workspace_routing(
@mcp.tool(
"create_memory_project",
title="Create Memory Project",
tags={"projects"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def create_memory_project(
@@ -525,9 +517,8 @@ async def create_memory_project(
workspace: Optional cloud workspace selector to create the project in. Slug is
preferred for AI callers, but tenant_id and unique name are also accepted.
When omitted, the connection's default workspace is used. Discover values
via `list_workspaces`. A workspace selector implies cloud routing:
without cloud credentials the call fails fast instead of silently
creating a local project (#954).
via `list_workspaces`. In local mode the selector is passed through
without slug resolution.
output_format: "text" returns the existing human-readable result text.
"json" returns structured project creation metadata.
context: Optional FastMCP context for progress/status logging.
@@ -645,8 +636,6 @@ async def create_memory_project(
@mcp.tool(
title="Delete Project",
tags={"projects"},
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(
@@ -665,9 +654,8 @@ async def delete_project(
workspace: Optional cloud workspace selector to delete the project from.
Slug is preferred for AI callers, but tenant_id and unique name are
also accepted. When omitted, the connection's default workspace is
used. A workspace selector implies cloud routing: without cloud
credentials the call fails fast, matching create_memory_project
behavior (#954).
used. In local mode the selector is passed through without slug
resolution, matching create_memory_project behavior.
Returns:
Confirmation message about project deletion
@@ -155,9 +155,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
@mcp.tool(
title="Read Content",
description="Read a file's raw content by path or permalink",
tags={"notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_content(
+34 -117
View File
@@ -1,14 +1,13 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Annotated, Optional, Literal, cast
from typing import Optional, Literal, cast
import logfire
import yaml
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
@@ -21,17 +20,6 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
# The title-match fallback exists to find THE note by exact title, so it scans
# fixed-size pages of title results instead of the caller's page/page_size
# (which apply only to the text-search suggestion listing).
_TITLE_LOOKUP_PAGE_SIZE = 10
# Hard safety cap on title-lookup pages. The loop normally stops as soon as an
# exact match is found or results run out (has_more=False); the cap only bounds
# pathological knowledge bases where hundreds of fuzzy titles contain the
# queried phrase. Exhausting the cap falls through to the suggestion behavior.
_TITLE_LOOKUP_MAX_PAGES = 10
def _is_exact_title_match(identifier: str, title: str) -> bool:
"""Return True when identifier exactly matches a title (case-insensitive)."""
@@ -74,9 +62,7 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
@mcp.tool(
title="Read Note",
description="Read a markdown note by title or permalink.",
tags={"notes"},
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -85,21 +71,6 @@ async def read_note(
identifier: str,
project: Optional[str] = None,
project_id: Optional[str] = None,
# Accept common pagination aliases models reach for from training data
# (page_number/limit/per_page), matching the sibling navigation tools
# (search_notes, build_context, recent_activity). The schema advertises
# only the canonical names; aliases are silently mapped at validation time.
# `offset` is intentionally NOT aliased: offset is item-indexed (skip N
# items) while page is a 1-indexed page-number, so direct aliasing would
# return the wrong slice.
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
output_format: Literal["text", "json"] = "text",
include_frontmatter: bool = False,
context: Context | None = None,
@@ -128,14 +99,6 @@ async def read_note(
workspaces. Takes precedence over `project`. Get from list_memory_projects().
identifier: The title or permalink of the note to read
Can be a full memory:// URL, a permalink, a title, or search text
page: Page of fallback-search results to use when the identifier does not
resolve to a note directly (default: 1). A direct or exact-title match
always returns the full note content page/page_size never chunk the
note itself, and the title-match lookup pages through fixed-size pages
of title results until an exact match is found or results are
exhausted, regardless of page or page_size.
page_size: Number of fallback-search results per page (default: 10). When no
match is found, this caps how many related-note suggestions are listed.
output_format: "text" returns markdown content or guidance text.
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
include_frontmatter: When output_format="json", whether content should include the
@@ -159,9 +122,6 @@ async def read_note(
# Read recent meeting notes
read_note("team-docs", "Weekly Standup")
# Page through fallback-search suggestions when nothing matches directly
read_note("unknown topic", page=2, page_size=5)
Raises:
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal
@@ -170,15 +130,6 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative).
# Why: both flow into the fallback search's server-side slicing, where
# non-positive values produce empty result pages with unreachable
# pagination. Fail fast, matching search_notes/build_context.
if page < 1:
raise ValueError(f"page must be >= 1, got {page}")
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Detect project from a memory URL or permalink prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
@@ -196,8 +147,6 @@ async def read_note(
tool_name="read_note",
requested_project=project,
requested_project_id=project_id,
page=page,
page_size=page_size,
output_format=output_format,
include_frontmatter=include_frontmatter,
):
@@ -296,7 +245,7 @@ async def read_note(
]
async def _search_candidates(
identifier_text: str, *, title_only: bool, lookup_page: int = 1
identifier_text: str, *, title_only: bool
) -> dict[str, object]:
# Trigger: direct entity resolution failed for the caller's identifier.
# Why: search_notes applies the same memory:// normalization and tool-level
@@ -307,24 +256,11 @@ async def read_note(
# Without this, project names that collide across workspaces could re-resolve
# to a different tenant via the default-workspace fallback (CLI/context=None).
search_type = "title" if title_only else "text"
# Trigger: title_only — the title search exists to find THE note by
# exact title, not to page through suggestions.
# Why: paginating it by the caller's page would skip an exact match
# sitting on page 1 (read_note("Exact Title", page=2)), and a
# small caller page_size could let a higher-ranked fuzzy title
# displace the exact match out of the lookup window
# (read_note("Foo Bar", page_size=1) when "Foo Bar Foo Bar"
# ranks first) — both returning suggestions instead of the note.
# Outcome: title lookup uses its own lookup_page with a fixed lookup
# size, walked by the caller below; caller page/page_size
# apply only to the text-search suggestion listing.
response = await search_notes(
project=active_project.name,
project_id=active_project.external_id,
query=identifier_text,
search_type=search_type,
page=lookup_page if title_only else page,
page_size=_TITLE_LOOKUP_PAGE_SIZE if title_only else page_size,
output_format="json",
context=context,
)
@@ -361,24 +297,12 @@ async def read_note(
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
# Continue to fallback methods
# Fallback 1: Try title search via API, walking fixed-size pages of
# title results until an exact match is found or results run out.
# A single page is not enough: when more than _TITLE_LOOKUP_PAGE_SIZE
# higher-ranked fuzzy titles contain the queried phrase, the exact
# title lands on a later page and a one-page lookup would miss it.
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
result: dict[str, object] | None = None
for lookup_page in range(1, _TITLE_LOOKUP_MAX_PAGES + 1):
title_results = await _search_candidates(
identifier, title_only=True, lookup_page=lookup_page
)
title_candidates = _search_results(title_results)
if not title_candidates:
logger.info(
f"No results in title search for: {identifier} "
f"in project {active_project.name}"
)
break
title_results = await _search_candidates(identifier, title_only=True)
title_candidates = _search_results(title_results)
if title_candidates:
# Trigger: direct resolution failed and title search returned candidates.
# Why: avoid returning unrelated notes when search yields only fuzzy matches.
# Outcome: fetch content only when a true exact title match exists.
@@ -390,37 +314,33 @@ async def read_note(
),
None,
)
if result is not None:
break
# Trigger: this page held only fuzzy titles and the server reports
# no further pages (has_more is False or absent).
# Why: continuing past the last page would issue empty lookups.
# Outcome: give up on the title fallback and try text search below.
if title_results.get("has_more") is not True:
if not result:
logger.info(f"No exact title match found for: {identifier}")
break
if result is not None and _result_permalink(result):
try:
# Resolve the permalink to entity ID
entity_id = await knowledge_client.resolve_entity(
_result_permalink(result) or "", strict=True
)
# Fetch content using the entity ID
response = await resource_client.read(entity_id)
if response.status_code == 200:
logger.info(
f"Found note by exact title search: {_result_permalink(result)}"
elif _result_permalink(result):
try:
# Resolve the permalink to entity ID
entity_id = await knowledge_client.resolve_entity(
_result_permalink(result) or "", strict=True
)
if output_format == "json":
return await _read_json_payload(entity_id)
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
)
# Fetch content using the entity ID
response = await resource_client.read(entity_id)
if response.status_code == 200:
logger.info(
f"Found note by exact title search: {_result_permalink(result)}"
)
if output_format == "json":
return await _read_json_payload(entity_id)
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
)
else:
logger.info(
f"No results in title search for: {identifier} in project {active_project.name}"
)
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
@@ -432,9 +352,6 @@ async def read_note(
if output_format == "json":
return _empty_json_payload()
return format_not_found_message(active_project.name, identifier)
# The fallback search is paginated server-side to page_size, so list
# the whole returned page instead of a hardcoded cap — otherwise the
# caller's page_size would be silently ignored past the cap.
if output_format == "json":
payload = _empty_json_payload()
payload["related_results"] = [
@@ -443,10 +360,10 @@ async def read_note(
"permalink": _result_permalink(result),
"file_path": _result_file_path(result),
}
for result in text_candidates
for result in text_candidates[:5]
]
return payload
return format_related_results(active_project.name, identifier, text_candidates)
return format_related_results(active_project.name, identifier, text_candidates[:5])
def format_not_found_message(project: str | None, identifier: str) -> str:
@@ -26,7 +26,6 @@ from basic_memory.schemas.search import SearchItemType
@mcp.tool(
title="Recent Activity",
description="""Get recent activity for a project or across all projects.
Timeframe supports natural language formats like:
@@ -37,7 +36,6 @@ from basic_memory.schemas.search import SearchItemType
- "3 weeks ago"
Or standard formats like "7d"
""",
tags={"navigation", "notes"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def recent_activity(
+4 -4
View File
@@ -1,15 +1,15 @@
"""Release notes MCP tool."""
from basic_memory.mcp.resources.discovery import load_discovery_resource
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool(
"release_notes",
title="Release Notes",
tags={"cloud"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def release_notes() -> str:
"""Return the latest product release notes for optional user review."""
return load_discovery_resource("release_notes.md")
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
return content_path.read_text(encoding="utf-8")
-6
View File
@@ -204,9 +204,7 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str:
@mcp.tool(
title="Validate Schema",
description="Validate notes against their Picoschema definitions.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_validate(
@@ -319,9 +317,7 @@ async def schema_validate(
@mcp.tool(
title="Infer Schema",
description="Analyze existing notes and suggest a Picoschema definition.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_infer(
@@ -442,9 +438,7 @@ async def schema_infer(
@mcp.tool(
title="Schema Diff",
description="Detect drift between a schema definition and actual note usage.",
tags={"schema"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_diff(
+7 -54
View File
@@ -11,13 +11,7 @@ from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.utils import (
build_canonical_permalink,
coerce_dict,
parse_str_list,
parse_tags,
strict_search_tags,
)
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
@@ -612,9 +606,7 @@ async def _search_all_projects(
@mcp.tool(
title="Search Notes",
description="Search across all content in the knowledge base with advanced syntax support.",
tags={"search"},
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -651,30 +643,24 @@ async def search_notes(
# Plural-vs-singular trips models constantly. Accept the singular too.
note_types: Annotated[
List[str] | None,
# parse_str_list, not coerce_list: "note,task" must split into ["note", "task"]
# consistent with how tags are handled (#910/#930). coerce_list wraps the whole
# comma string as the single literal type ["note,task"], which matches nothing.
BeforeValidator(parse_str_list),
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("note_types", "note_type", "types")),
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
"Accepts a list, a comma-separated string (e.g. 'note,task'), or a JSON-array string. "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
BeforeValidator(parse_str_list),
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("entity_types", "entity_type")),
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead. "
"Accepts a list, a comma-separated string (e.g. 'entity,observation'), or a JSON-array string.",
"'Chapter' here — use note_types instead.",
] = None,
categories: Annotated[
List[str] | None,
BeforeValidator(parse_str_list),
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("categories", "category")),
"Filter observation results to these exact categories (e.g. ['requirement']). "
"Accepts a list, a comma-separated string (e.g. 'requirement,decision'), or a JSON-array string. "
"Pair with entity_types=['observation'] to return only observations whose "
"category matches exactly — not every row mentioning the word.",
] = None,
@@ -690,15 +676,9 @@ async def search_notes(
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
] = None,
# strict_search_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to
# match the tag: query shorthand below and write_note's documented tags convention
# (#910). coerce_list would wrap the comma string as the single literal tag
# ["a,b"], which matches nothing. Unlike bare parse_tags, the strict wrapper only
# splits str/list/None and lets Pydantic reject other types (42, {"a": 1}) with a
# clear validation error instead of stringifying them into junk tags.
tags: Annotated[
List[str] | None,
BeforeValidator(strict_search_tags),
BeforeValidator(coerce_list),
] = None,
status: Optional[str] = None,
min_similarity: Annotated[
@@ -815,9 +795,7 @@ async def search_notes(
observations whose category matches exactly.
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"].
Accepts a list (["a", "b"]) or a comma-separated string ("a,b"), matching the
write_note tags convention and the tag: query shorthand.
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
min_similarity: Optional float to override the global semantic_min_similarity threshold
for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision.
@@ -828,11 +806,6 @@ async def search_notes(
Formatted markdown text (output_format="text"), dict (output_format="json"),
or helpful error guidance string if search fails
Pagination note: `total` is exact only for text/title/permalink searches.
Vector and hybrid searches skip the count query (it would cost a second
semantic retrieval pass) and report `total: 0` even when results are
returned use `has_more` for pagination in those modes.
Examples:
# Basic text search
results = await search_notes("project planning")
@@ -906,17 +879,6 @@ async def search_notes(
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Trigger: list params arrived via a direct function call instead of the MCP layer.
# Why: the BeforeValidator annotations only run through MCP/Pydantic validation; direct
# callers (e.g. `bm tool search-notes --type note,task` in cli/commands/tool.py,
# which Typer collects as the one-element list ["note,task"]) would otherwise
# forward the comma string as one literal type that matches nothing (#930).
# Outcome: comma-split/list normalization applies on every path; parse_str_list is
# idempotent, so MCP-validated input passes through unchanged.
note_types = parse_str_list(note_types) if note_types is not None else []
entity_types = parse_str_list(entity_types) if entity_types is not None else []
categories = parse_str_list(categories) if categories is not None else []
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
@@ -925,15 +887,6 @@ async def search_notes(
# so preserve their original casing (unlike the lowercased note_types).
categories = categories or []
# Trigger: tags arrived via a direct function call instead of the MCP layer.
# Why: the BeforeValidator above only runs through MCP/Pydantic validation; direct
# callers (e.g. `bm tool search-notes --tag a,b` in cli/commands/tool.py, which
# Typer collects as the one-element list ["a,b"]) would otherwise forward the
# comma string as one literal tag that matches nothing (#910).
# Outcome: comma-split/list normalization applies on every path; parse_tags is
# idempotent, so MCP-validated input passes through unchanged.
tags = parse_tags(tags) or None
# Parse tag:<value> shorthand at tool level so it works with all search modes.
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
# Without this, hybrid/vector modes fail because they require non-empty text,
-4
View File
@@ -18,9 +18,7 @@ def _text_block(message: str) -> List[ContentBlock]:
@mcp.tool(
title="Search Notes (UI)",
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
tags={"search", "ui"},
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)
@@ -94,9 +92,7 @@ async def search_notes_ui(
@mcp.tool(
title="Read Note (UI)",
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
tags={"notes", "ui"},
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)

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