Compare commits

..

127 Commits

Author SHA1 Message Date
phernandez 6e4bb72f10 chore: update version to 0.19.2 for v0.19.2 release 2026-03-09 23:42:10 -05:00
phernandez 11b0e31e24 docs: add v0.19.2 changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-09 23:41:40 -05:00
Paul Hernandez a5c9e77f16 fix: coerce string params to list/dict in MCP tools (#657)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:19:07 -05:00
Paul Hernandez 30a89357cb fix(core): handle SQLite and Windows semantic regressions (#655)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-09 22:18:17 -05:00
phernandez 222ec5d3b6 chore: update version to 0.19.1 for v0.19.1 release 2026-03-08 18:09:04 -05:00
phernandez d42aec7ea9 docs: add v0.19.1 changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 18:04:30 -05:00
Paul Hernandez 9809b469c6 fix: enforce strict entity resolution in destructive MCP tools (#650)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:56:07 -05:00
phernandez 76ac880f2d feat(api): add GET /knowledge/graph endpoint for full graph visualization
Returns all entities and resolved relations in a flat node/edge format
optimized for graph rendering. Replaces the frontend's use of the
recent memory endpoint which only returned a subset of relations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 11:52:17 -05:00
Paul Hernandez ad3f2650d9 feat: add insert_before_section and insert_after_section edit operations (#648)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:54:10 -05:00
dependabot[bot] d6508d985c chore(deps): bump authlib from 1.6.6 to 1.6.7 in the uv group across 1 directory (#645)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-08 10:53:52 -05:00
phernandez 7b95b9f37b docs: add What's New in v0.19.0 section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 10:29:06 -05:00
phernandez 0bce4be1a6 chore: update version to 0.19.0 for v0.19.0 release 2026-03-07 14:27:43 -06:00
phernandez a316424edf docs: add v0.19.0 changelog entry
Comprehensive changelog for 114 commits since v0.18.5 covering semantic
vector search, schema system, per-project cloud routing, FastMCP 3.0
upgrade, CLI overhaul, and numerous bug fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 14:27:29 -06:00
phernandez af71cf4896 fix(test): clear search_vector_chunks before embedding backfill test
Test was polluted by other tests leaving rows in search_vector_chunks,
causing _needs_semantic_embedding_backfill to return False.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 13:54:58 -06:00
phernandez e846ae85d8 fix: semantic embeddings not generated on fresh DB or upgrade
The previous backfill trigger relied on Alembic revision tracking, but
alembic_version only stores the head revision — intermediate revisions
(like the backfill trigger) are invisible after a multi-step upgrade or
fresh DB creation.

Three changes fix this:

1. Replace Alembic revision check with a simple "entities exist but
   embeddings are empty" check that works regardless of migration path
2. Generate embeddings during sync — after FTS indexing, batch-embed all
   synced entities at the end of the sync operation
3. Add background backfill at MCP startup for the upgrade path (entities
   already exist, no embeddings) without blocking server readiness

Also adds clear startup logging for semantic embedding status so issues
are easy to spot in the logs.

📋 Covers: fresh DB, upgrade from pre-embedding version, db reset,
   interrupted backfill

Signed-off-by: Pedro Hernandez <pedro@basicmachines.co>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 13:23:39 -06:00
phernandez 63e4bcdf1d fix: clarify search_notes parameter naming and fix note_types case sensitivity
- Add Annotated descriptions to note_types and entity_types parameters so
  LLMs can distinguish frontmatter type filtering from knowledge graph item
  type filtering (search.py, ui_sdk.py)
- Lowercase note_types values at filter time so "Chapter" matches stored
  "chapter"
- Fix misleading entity_types references in schema.py guidance strings
  (should be note_types)
- Add permalink pattern documentation note about full path matching
- Add test for note_types case-insensitive lowercasing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-05 15:08:52 -06:00
phernandez f23dd0474b fix(test): patch API fallback in project_context tests for Postgres
In Postgres test mode, stale dependency_overrides on the module-level
FastAPI app allow _resolve_default_project_from_api() to query a live
database and return 'test-project' even when the test sets
default_project=None. Monkeypatch the async fallback in the three
affected tests to isolate config-based resolution from API leakage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-04 15:34:35 -06:00
phernandez fced804438 fix(test): update integration test for DB default project fallback (#644)
The test previously asserted that write_note fails when ConfigManager
has no default_project. With the API fallback, it now correctly
resolves to the database is_default project. Updated the test to
verify this fallback behavior instead of expecting an error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-04 13:27:03 -06:00
phernandez 1fdc9fdc69 fix: resolve_project_parameter falls back to projects API for default (#644)
In cloud mode, ConfigManager has no local config so default_project
is always None. Add API fallback in resolve_project_parameter that
queries /v2/projects/ for the default_project field. This fixes all
MCP tools that rely on project resolution (recent_activity, etc).

Removed discovery mode tests that simulated an invalid state by
clearing is_default — there must always be a default project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-04 13:17:16 -06:00
phernandez 2feecdfaf7 fix: ChatGPT search/fetch tools broken in cloud mode (#644)
Both search() and fetch() read default_project from ConfigManager,
which returns None in cloud mode. Remove the manual ConfigManager
lookup and let the underlying search_notes/read_note resolve the
project via get_project_client(), which works in both modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-04 12:40:40 -06:00
phernandez 195229f78e fix: resolve default_project returning null in cloud mode (#644)
In cloud mode, ConfigManager has no local config file so
default_project always returned None. Add async
get_default_project_name() on ProjectService that falls back
to the database is_default flag when ConfigManager returns None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-04 11:57:15 -06:00
phernandez d15f6a8427 Add batched vector sync orchestration across repositories 2026-03-03 16:36:06 -06:00
phernandez b8a3a14ad2 Add semantic query timing and FastEmbed parallel guardrails
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-03 14:04:03 -06:00
phernandez 9b199c6dcb fix: add FastEmbed runtime tuning knobs and provider caching
Add configurable cache_dir, threads, and parallel settings for FastEmbed
to support cloud deployments where defaults fail. Cache embedding providers
at the process level to avoid re-creating heavy ONNX model instances.

- Add semantic_embedding_cache_dir, semantic_embedding_threads, and
  semantic_embedding_parallel config fields
- Thread-safe provider cache with double-checked locking in factory
- Forward runtime knobs through to TextEmbedding and embed() calls
- Fix if/elif chain in factory for correct error handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-03 10:26:47 -06:00
phernandez fe4a7b1622 fix: update analytics test mocks for non-daemon thread change
Thread constructor no longer receives daemon=True, update mock
signatures to match. Also assert on the new "type": "event" field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-02 21:26:54 -06:00
phernandez 7d2012a82c fix(cli): fix Umami analytics event delivery
Three issues prevented CLI analytics from reaching the Umami dashboard:

1. Wrong API endpoint — cloud.umami.is rejects /api/send, the JS tracker
   uses api-gateway.umami.dev
2. Missing "type": "event" top-level field required by Umami v2 API
3. Non-browser User-Agent ("basic-memory-cli/...") triggers Umami's bot
   detection, which silently drops events with {"beep":"boop"} 🤖
4. Daemon thread was killed before HTTP request completed on fast commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-02 19:24:10 -06:00
phernandez 7f2d4d2a6f fix: three cloud-testing bugs (#640, #641, #642)
🔧 #640 — LinkResolver selects worst match instead of best
Replace `min(results, key=lambda x: x.score)` with `results[0]`.
Both SQLite and Postgres return results sorted best-first in SQL,
so using `results[0]` is backend-agnostic and correct.

🔧 #641 — search_notes output_format="text" returns raw Pydantic model
Add `_format_search_markdown()` that formats SearchResponse as readable
markdown with title, permalink, score, and matched snippet per result.
Update prompts to use `output_format="json"` since they need structured
data for result counting and branching logic.

🔧 #642 — metadata_filters with `note_type` key returns empty results
Add `_METADATA_KEY_ALIASES` mapping at the tool level that aliases
`note_type` → `type` before passing metadata_filters to the search query.
The frontmatter field is `type`, not `note_type`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-02 12:58:22 -06:00
phernandez 8b7f39ee9b docs: update AI assistant guides for v0.19.0, fix multi-tag search parsing
Update both the compact and extended AI assistant guides with v0.19.0 changes:
- 📝 write_note overwrite guard: callout, edit_note examples, overwrite=True
- 🔍 Expanded search section: all search types, tag: shorthand, filter-only
  searches, metadata_filters operators, min_similarity
- ⚠️ "Note already exists" error handling pattern
- 📋 Tool quick reference: updated params, added list_workspaces
- 🔗 memory:// URL: added cross-project format
- ✏️ Best practice: prefer edit_note for updates

Fix tag: shorthand parsing to handle multiple tags anywhere in the query.
Old parser only handled queries starting with "tag:" and broke on
"tag:coffee AND tag:brewing". New parser uses re.findall to extract all
tag:value tokens, strips boolean connectors, and preserves remaining text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-02 09:50:22 -06:00
phernandez a368d06fd2 fix: improve cloud CLI status and error messages
- Simplify `bm cloud status` output: remove verbose health check details
  (status/version/timestamp), show simple "Cloud connected" / "Cloud not
  connected" message instead
- Improve `bm reindex --project` error for cloud projects: distinguish
  between "project not found" and "project is cloud-only" with a helpful
  message explaining reindexing is a local operation
- Improve `bm project list` cloud error message: show the actual error
  and soften the credentials suggestion
- Add tests for cloud status command (5 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 20:24:11 -06:00
phernandez 3a2b80b7e9 fix: remove broken CI coverage infrastructure
The coverage collection had multiple issues:
- Wrong pytest markers caused 0 tests to run
- Postgres jobs silently skipped artifact uploads
- Coverage Summary job failed when artifacts were missing
- uv venv picked wrong Python version for coverage jobs

Simplify: every job just runs tests via `just` recipes. No more
dual code paths, artifact uploads, or summary aggregation job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 13:18:51 -06:00
phernandez bd5099120f fix: CI Postgres coverage jobs used wrong pytest marker (-m postgres)
The coverage code paths for Postgres unit and integration jobs filtered
with `-m postgres`, but no tests use that marker. Postgres is selected
via BASIC_MEMORY_TEST_POSTGRES=1 env var. This caused 0 tests to run
on Python 3.12 (the only version with coverage: true).

- Postgres unit: remove `-m postgres` (matches `just test-unit-postgres`)
- Postgres integration: use `-m "not semantic"` (matches `just test-int-postgres`)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 12:47:38 -06:00
phernandez 8e64caf784 fix: list_workspaces bypasses factory pattern on cloud MCP server (#636)
Add set_workspace_provider() injection point so the cloud MCP server can
list workspaces by querying its own database directly, instead of making
an HTTP round-trip to the control-plane API with credentials it doesn't have.

🔧 Mirrors the existing set_client_factory() pattern in async_client.py
🧪 Adds 3 tests for provider injection, fallback, and context caching
🩹 Updates build_context test assertions for v0.18 backward compat fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 12:08:25 -06:00
phernandez ccb5740920 fix: create backup before config migration overwrites old format (#637)
When load_config() detects a legacy config format and resaves it, the old
config.json was overwritten in-place with no recovery path. Users switching
between dev and released versions would lose their config.

Now creates config.json.bak before the migration resave so users can revert
if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 12:08:25 -06:00
phernandez 373893a7ee fix: restore API backward compatibility for v0.18.x clients (#638)
v0.18.5 clients (homebrew) fail against v0.19.0 servers because:
- `entity_type` was renamed to `note_type` in EntityResponse
- 13 fields gained `Field(exclude=True)` and vanished from JSON

🔧 EntityResponse: add `entity_type` computed_field mirroring `note_type`
🔧 memory.py: remove `exclude=True` from 13 fields across EntitySummary,
   RelationSummary, ObservationSummary, and MemoryMetadata
🔧 Add v0.18 backward-compat contract tests

Closes #638

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 12:08:25 -06:00
phernandez 1987581ee3 docs: update v0.19.0 release notes and apply formatting fixes
Add #634 (stale schema metadata) to bug fixes section.
Apply ruff formatting to schema.py, write_note.py, test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-01 12:08:25 -06:00
Drew Cain 59134affa4 fix: read schema definitions from file instead of stale database metadata (#635)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-03-01 09:41:22 -06:00
phernandez c7d97decd6 docs: update v0.19.0 release notes with recent commits
Add write_note overwrite guard (#632) to new capabilities, 8 bug fixes
(#631, #630, #30, #31, #28, plus schema_validate/Post/frontmatter fixes),
and write_note idempotency breaking change to upgrade notes. Bump commit
count from 80+ to 90+.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-28 13:53:29 -06:00
phernandez 5d5efa02a5 fix: format schema_infer and schema_diff as markdown text (#28)
schema_infer and schema_diff returned raw Pydantic models in text mode,
causing LLMs to render field names as "undefined". Add text formatters
(_format_inference_report, _format_drift_report) matching the existing
_format_validation_report pattern. CLI paths are unaffected — they
always use output_format="json".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-28 12:24:38 -06:00
phernandez 4f12182e28 fix: parse tag: prefix at MCP tool level to avoid hybrid search failure (#30)
When semantic search is enabled (default), `search_notes(query="tag:security")`
failed because the HYBRID retrieval mode requires non-empty text, but the
service-layer tag: parser clears the text after the mode is already set.

Parse tag: prefix at the tool level before search mode selection, converting it
to a tags filter. This works with all search modes (text, hybrid, vector).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-28 11:53:05 -06:00
phernandez 2c5b63c4a2 fix: default search_notes to entity-level results (#31)
search_notes was returning individual observations and relations as
separate top-level results, wasting the result limit and creating
confusing UX. Default entity_types to ["entity"] when the caller
doesn't specify it — the entity row already indexes full file content,
so no matches are lost. Users can still override with explicit
entity_types=["observation"] etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-28 11:28:51 -06:00
phernandez c4c9f842ea fix: schema_validate identifier resolution and text rendering
Fixes issues #29 and #33 from openclaw-basic-memory.

🔧 Identifier resolution (#33):
- Router used get_by_permalink() which only matched exact permalinks.
  Replaced with link_resolver.resolve_link() so titles, paths, and
  fuzzy matches work consistently with other tools like read_note.
- Set total_entities=1 and total_notes=len(results) on single-note
  path for consistency with batch path.
- Guards for "no notes" and "no schema" now fire for identifier-based
  validation too, not just note_type-based.

🎨 Text rendering (#29):
- Tool returned raw Pydantic model which LLMs rendered as
  "undefined — invalid". Now returns pre-formatted markdown.
- Router uses entity.title (with permalink fallback) as note_identifier
  for human-readable output in both text and JSON modes.
- JSON output (output_format="json") unchanged for CLI compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-28 10:40:01 -06:00
phernandez dc0ca71c18 fix: avoid Post(**metadata) crash when frontmatter contains 'content' or 'handler' keys
frontmatter.Post.__init__ takes `content` and `handler` as positional
parameters. When user YAML frontmatter contains these as field names,
unpacking metadata via **kwargs causes "got multiple values for argument
'content'".

Replace frontmatter.loads() with frontmatter.parse() + Post() + update()
in entity_parser, and replace the **metadata unpacking in
entity_service.update_entity() with the same safe pattern.

Fixes basic-memory-cloud#375

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-27 22:52:16 -06:00
phernandez 236ae268aa fix: coerce list frontmatter values to strings for title and type fields
YAML block sequence syntax can cause PyYAML to parse scalar fields like
`title` and `type` as lists instead of strings. Downstream code calls
.strip()/.casefold() on these values, crashing with
"'list' object has no attribute 'strip'".

Add _coerce_to_string() helper that joins list items with ", " and apply
it in entity_parser.parse_markdown_content() and
entity_service.fast_edit_entity() where these fields are extracted.

Fixes basic-memory-cloud#376

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-27 22:45:09 -06:00
Paul Hernandez bd5923a370 feat: add overwrite guard to write_note tool (#632)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:12:38 -06:00
jope-bm 4ea5396ddd fix: skip workspace resolution when client factory is active (#630)
Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:40:04 -06:00
Paul Hernandez e97eafa55a fix: build_context related_results schema validation failure (#631)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:30:44 -06:00
bm-clawd 254e30423d docs: update v0.19.0 release notes with post-draft changes (#626)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:36:27 -06:00
phernandez 496af07ced fix: resolve pyright possibly-unbound errors in edit_note
Initialize entity_id and result before the try/except block and
add assertion before the formatting section to help pyright prove
result is always bound on all code paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 20:10:35 -06:00
bm-clawd b5667f9b55 docs: Add UTM tracking to README links (#611) 2026-02-26 19:55:33 -06:00
phernandez 54b968b93c fix: reduce excessive log volume by demoting per-request noise to DEBUG (#613)
Demote high-frequency per-request/per-item logs from INFO to DEBUG:
- 🔇 Client routing decisions (async_client.py) — logged every MCP tool call
- 🔇 DB migration checks (db.py) — logged every ASGI client creation
- 🔇 Vector table ensure/ready (sqlite + postgres search repos) — logged every search
- 🔇 Per-entity search index start/complete (search_service.py) — logged every file sync
- 🔇 Incremental scan details + per-file permalink updates (sync_service.py)
- 🔇 MCP search tool params and no-results (search.py)
- 📉 Log retention: "10 days" → 5 files (~50MB cap)

API v2 request/response logs remain at INFO for observability.

Closes #613

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 19:48:24 -06:00
phernandez e59b5cb6d9 feat: edit_note append/prepend auto-create note if not found (#614)
When append or prepend targets a non-existent note, the tool now
creates the file automatically instead of returning an error. This
eliminates silent failures for plugins (like openclaw) that use
edit_note(append) to build daily conversation notes — on the first
message of each day, the note didn't exist yet.

- find_replace and replace_section still require an existing note
- JSON output now includes `fileCreated: bool` in all responses
- Path traversal security check applied to auto-created directories
- Updated error messages to suggest append/prepend for missing notes

🧪 25 unit tests, 14 MCP integration tests, 10 CLI integration tests — all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 19:00:34 -06:00
phernandez f0335b998e fix: handle quoted picoschema enum strings in YAML frontmatter (#612)
The picoschema enum-with-description syntax `[val1, val2], description`
is invalid YAML. Users must quote it so YAML parses it as a string.
This adds `_parse_enum_string()` to extract enum values and description
from the resulting string value (e.g., "[active, blocked], current state").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 18:10:48 -06:00
phernandez 66effb04a7 fix: upgrade cryptography and python-multipart for security advisories
- cryptography 46.0.3 → 46.0.5 (subgroup attack on SECT curves)
- python-multipart 0.0.21 → 0.0.22 (arbitrary file write via non-default config)
- pillow alert dismissed — blocked by fastembed 0.7.4 pinning <12.0 (tracking qdrant/fastembed#606)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 14:42:20 -06:00
phernandez 9331126ba1 feat: improve content hit rate in search results (#609)
Three changes to surface more answer text in search results:

- Populate matched_chunk_text for FTS-only hybrid results from content_snippet,
  preventing fallback to truncated content when vector search has no match
- Increase TOP_CHUNKS_PER_RESULT from 3 to 5, catching answers in deeper chunks
  for large notes (~2700 → ~4500 chars of matched context)
- Increase CONTENT_DISPLAY_LIMIT from 2000 to 4000, doubling the safety-net
  content truncation for results without matched_chunk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 11:55:38 -06:00
phernandez 3bbb44af0b feat: add --json output to CLI commands for scripting and CI
Add machine-readable JSON output to five CLI commands:
- `bm status --json` — sync report
- `bm project list --json` — structured project list
- `bm schema validate --json` — validation report
- `bm schema infer --json` — inference report
- `bm schema diff --json` — drift report

Refactored `run_status()` to return data instead of printing directly,
improving testability. Follows the established `bm project info --json`
pattern using `print()` for clean JSON (no Rich markup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 10:33:00 -06:00
phernandez f9b2a075a9 feat: return richer content context in search results (#609)
Two strategies to improve content hit rate when the right document is found:

1. Small notes (<=2000 chars): return full content_snippet as matched_chunk
   so the answer is always present for correctly-retrieved small notes
2. Large notes: return top-3 chunks by similarity joined with \n---\n
   instead of just the single best chunk (~2700 chars vs ~900 chars)

Also raises CONTENT_DISPLAY_LIMIT from 250 to 2000 for richer FTS results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-26 08:36:54 -06:00
phernandez 0a3f3f07f8 fix: run coverage instead of tests on 3.12/ubuntu, not in addition to
Previous commit still ran tests twice on the coverage matrix entry.
Now the 3.12/ubuntu/main combo runs with coverage directly, and all
other matrix entries run without. No duplicate work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 23:36:46 -06:00
phernandez 184ea6d9fa fix: collect coverage from test jobs instead of re-running all tests
The coverage summary job was re-running the entire SQLite + Postgres test
suite from scratch (~60 min), duplicating work already done by upstream jobs.
It consistently timed out.

Now each test job collects coverage data and uploads it as an artifact.
The coverage job just downloads, combines, and reports — should take <1 min.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 23:33:15 -06:00
phernandez f5a0e942b0 fix: replace RRF with score-based fusion in hybrid search (#577)
RRF compressed all fused scores to ~0.016, destroying ranking differentiation.
The new formula `max(vec, fts) + FUSION_BONUS * min(vec, fts)` preserves
dominant signals and rewards dual-source agreement.

Changes:
- Remove RRF_K constant; add FUSION_BONUS (0.3) and FTS_GATE_THRESHOLD (0.0)
- Use raw vector similarity scores instead of re-normalizing by vec_max
- Zero-score results now produce zero fused score (no 0.1 weight floor)
- Rename test_hybrid_rrf.py → test_hybrid_fusion.py with updated assertions
- Update docs and docstrings to reflect score-based fusion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 22:53:00 -06:00
phernandez e46555bf2c fix: guard against closed streams in promo and missing vector tables (#579, #607)
- Wrap isatty() in _is_interactive_session() with try/except ValueError
  so MCP stdio transport shutdown no longer produces noisy tracebacks
- Check both search_vector_chunks AND search_vector_embeddings exist
  before running JOIN queries in get_embedding_status(), fixing
  OperationalError when only the chunks table is present
- Add test for closed-stream scenario

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 18:01:59 -06:00
phernandez 74e6afdc0f fix: create search_vector_chunks in test fixtures for Postgres compatibility
Embedding status tests were creating search_vector_chunks inline using
SQLite-only DDL (AUTOINCREMENT). Added Postgres DDL constants to
models/search.py and wired them into the test fixture so both backends
create the table at setup time — matching what the Alembic migration
does in production.

Also fixed stub search_vector_embeddings to use chunk_id (Postgres
column name) instead of rowid, and added inter-test cleanup to prevent
ordering-dependent failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 17:30:35 -06:00
phernandez aa635b8a8b fix: accept null for expected_replacements in edit_note (#606)
MCP clients may send explicit `null` for unused optional fields.
`expected_replacements: int = 1` caused FastMCP's JSON Schema validation
to reject null before the function body ran. Changed to `Optional[int] = None`
with an effective default resolved inside the function body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 14:18:53 -06:00
phernandez 1856d4b462 fix: update test_project_info assertions for dashboard format
The htop-inspired dashboard (3004d0d1) changed the project info output
from "Basic Memory Project Info" / "Statistics" to project name title
with "Knowledge Graph" section. Update test assertions to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 13:38:01 -06:00
phernandez 73413486bc feat: merge search_by_metadata into search_notes with optional query
Make `query` optional in `search_notes` so it becomes the single search tool.
Remove `search_by_metadata` entirely — it was unreleased and redundant since
`search_notes` already supports `metadata_filters`, `tags`, and `status`.

- 🔧 `query` param is now `Optional[str] = None`
- 🛡️ Added None guards for project detection and URL resolution
-  Added `no_criteria()` validation with helpful error message
- 🗑️ Deleted `search_by_metadata` tool, imports, tests, and contract entry
- 📝 Updated docs, README, and v0.19.0 release notes

Closes #605

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 13:30:42 -06:00
phernandez b09eca1698 feat: add EmbeddingStatus schema and get_embedding_status() service method
Add EmbeddingStatus model to project_info schemas and wire it into
ProjectInfoResponse. ProjectService.get_embedding_status() queries
vector tables for chunk/embedding counts, detects orphaned chunks
and missing embeddings, and recommends reindex when appropriate.
Handles both SQLite and Postgres backends. 🔍

Includes 6 unit tests covering: disabled search, missing vector tables,
entities without chunks, orphaned chunks, healthy state, and integration
with get_project_info().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 11:44:50 -06:00
phernandez 3004d0d1fe feat: replace project info with htop-inspired dashboard
Replace Layout-based display (expanded to terminal width) with a compact
Panel using Table.grid(expand=False). Add horizontal bar charts for note
types (top 5), embedding coverage bar with Unicode blocks, and colored
status dots. Removes verbose sections (most connected, recent activity,
available projects) in favor of a dense, visually engaging dashboard. 📊

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-25 11:11:58 -06:00
phernandez b6369d3d14 fix: cap sqlite-vec knn k parameter at 4096 limit
sqlite-vec enforces k <= 4096 for nearest-neighbor queries. Projects with
>4096 vector chunks would crash all vector/hybrid search because
candidate_limit = max(100, (limit + offset) * 10) exceeded this hard limit.

Clamp the knn k in _run_vector_query while keeping the outer SQL LIMIT
unclamped. Only affects SQLite — pgvector has no such constraint.

Fixes #604

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 23:23:15 -06:00
phernandez db6d0dcd9e docs: add tag: shorthand limitation note to search_notes docstring
The tag: query shorthand doesn't work with hybrid search (default when
semantic search is enabled) because it strips the text query. Document
the workaround: use search_type="text" or the tags parameter instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 20:45:42 -06:00
phernandez 5a5eb443ea docs: remove bm watch from v0.19.0 release notes
The watch command was removed from the codebase — drop the section
from the release notes so it doesn't advertise a non-existent feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 20:26:34 -06:00
phernandez e1df23d793 docs: add v0.19.0 release notes
Covers all 66 commits since v0.18.0: semantic vector search, schema
system, project-prefixed permalinks, per-project cloud routing,
FastMCP 3.0 upgrade, and 16 bug fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 20:20:23 -06:00
phernandez 0462a7d4ba docs: add telemetry disclosure and opt-out instructions to README
Transparent about what we collect (promo/login events only), what we
don't (no PII, no file contents, no per-command tracking), and how
to opt out (BASIC_MEMORY_NO_PROMOS=1).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 19:52:56 -06:00
phernandez 79db876bd6 feat: hardcode Umami analytics defaults for FOSS usage tracking
Bake in cloud.umami.is host and basic-memory-foss site ID so all
open-source installs send anonymous CLI events by default. Users
can still opt out with BASIC_MEMORY_NO_PROMOS=1 or override the
endpoint via env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 19:50:28 -06:00
phernandez 0130573342 fix: prompts call MCP tools directly, sync handles semantic errors, status uses local routing
- Prompts (search, continue_conversation) now call MCP tools directly
  instead of going through API endpoints, matching the recent_activity
  pattern and fixing #526 where prompts returned empty results
- sync_file catches SemanticDependenciesMissingError separately so
  entities are still returned successfully when vector embedding fails,
  with a clear warning instead of silent failure (#578)
- `bm status` and `bm doctor` default to local routing since they scan
  the local filesystem — cloud routing returned Docker-internal paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 19:36:11 -06:00
phernandez c372dfb09f fix: use LinkResolver fallback in build_context for flexible identifier matching (#582)
build_context now falls back to LinkResolver when an exact permalink lookup
returns empty results. This reuses the same resolution pipeline as read_note
(permalink candidates, title match, file path, FTS) so callers no longer
get empty results for valid note identifiers.

Also changes ensure_frontmatter_on_sync default to True — frontmatter is
now added during sync by default. Tests updated accordingly.

🔧 ContextService accepts optional LinkResolver, wired via DI in all 3 factory variants
 2027 unit + 278 integration tests passing

Closes #582

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 18:58:19 -06:00
phernandez d763d86798 fix: unify project path so path is always the local filesystem path
Cloud projects with bisync had a split-brain problem: `path` held a cloud
slug while the actual local directory lived in `local_sync_path`. This caused
`bm status` and file sync to fail for bisync'd cloud projects.

Changes:
- Config migration promotes `local_sync_path` → `path` for entries where
  `path` is a non-absolute cloud slug
- `ensure_project_paths_exists` skips cloud-only projects with slug paths
- `initialize_file_sync` and watch service now keep cloud projects that have
  an absolute local path (bisync copy) instead of skipping all cloud projects
- `sync-setup` and `project add --cloud --local-path` set both `path` and
  `local_sync_path` to the local directory
- `sync-setup` creates the project in the local DB for immediate MCP use
- `_get_sync_project` falls back from `local_sync_path` to `path`
- Config load errors now show user-friendly messages instead of stack traces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 15:08:09 -06:00
phernandez 538af97cba feat: show spinner while fetching cloud projects in bm project list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-24 12:05:33 -06:00
phernandez 054178d155 fix: parameterize SQL queries in search repository type filters
Replace f-string interpolation with parameterized queries for note_types,
search_item_types, and metadata filter paths to prevent SQL injection.

Fixes #591

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-23 22:16:46 -06:00
phernandez 18f00f861d fix: remove hardcoded "main" default from default_project (#575)
When a user's config.json had projects with names other than "main" and
no explicit default_project key, the hardcoded field default of "main"
would not match any project. The model_post_init fixup logic existed but
was untested and only handled the stale-name case, not the None case.

Changes:
- Change default_project field default from "main" to None
- Use model_fields_set to distinguish "config omitted the key" (auto-resolve
  to first project) from "user explicitly set None" (preserve for discovery mode)
- Split model_post_init into two branches: auto-resolve when not explicitly
  provided, correct stale names when explicitly set but invalid
- Remove # pragma: no cover from now-tested branches
- Add 10 new tests covering valid defaults, stale defaults, empty string,
  single project, config file round-trips, and discovery mode preservation

Fixes #575

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-23 21:59:15 -06:00
jope-bm da4d369c32 feat: add created_by and last_updated_by user tracking to Entity (#602)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:20:59 -07:00
Paul Hernandez 0f3889fdd0 fix: Return matched chunk text in search results (#601)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:01:20 -06:00
Paul Hernandez c44291830c chore: rename entity_type to note_type (#600)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:28:57 -06:00
phernandez e1cccba72d docs: add metadata search reference
Document the full structured metadata filter system — operators, MCP tools,
tag shortcuts, and CLI flags — which previously had no dedicated documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-22 00:45:10 -06:00
phernandez 6ff39076a0 fix: double-default display in project list + stale test updates
Use config.default_project as single source of truth for the Default
column in `bm project list`, removing checks against local DB and cloud
API is_default fields that could independently mark multiple projects.

Also updates tests that were out of date after get_project_mode changed
to default unknown projects to CLOUD:
- test_get_client_local_project_uses_asgi_transport: register "main" as LOCAL
- test_run_filters_cloud_projects_each_cycle: register local project in config
- test_new_project_addition_scenario: register projects as LOCAL in config
- test_get_project_mode_defaults_to_cloud: assert new CLOUD default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-22 00:33:25 -06:00
phernandez 56cefbaafd add OSS discount code to README.md
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 22:28:13 -06:00
phernandez 3337c7d1ff fix cli test for project move local only
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 21:02:13 -06:00
phernandez e248763a73 fix: update tool contract test for list_memory_projects workspace param
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 20:12:47 -06:00
phernandez 2e5813d31e feat: CLI refactoring + workspace-aware cloud project listing
Refactor CLI commands to use typed ProjectClient instead of raw HTTP calls,
and add workspace metadata to cloud project listings so users can distinguish
personal vs organization projects.

Key changes:
- 🔧 CLI commands now use ProjectClient typed API clients instead of
  call_get/call_post with manual URL construction
- 🏢 Cloud project listings include workspace_name, workspace_type, and
  workspace_tenant_id for each cloud-sourced project
- Pass config.default_workspace when fetching cloud projects via
  _fetch_cloud_projects() and CLI list_projects
- Add --workspace flag to `bm project list` for explicit workspace override
- Add "Workspace" column to CLI project list table
- Add `bm tool list-projects` and `bm tool list-workspaces` JSON commands
- Comprehensive tests for workspace passthrough, merge behavior, and CLI routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 20:04:03 -06:00
phernandez 2cde8d2659 clean up cli commands
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 16:06:43 -06:00
Paul Hernandez 9515130b2a feat: upgrade fastmcp 2.12.3 to 3.0.1 with tool annotations (#598)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:28:30 -06:00
Paul Hernandez b86dd6fb53 feat: Fix bm CLI runtime defects and audit regressions (#596)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-20 22:59:29 -06:00
Paul Hernandez 8451f2b1d7 feat: add frontmatter validation to schema system (#597)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:57:19 -06:00
Paul Hernandez ee0397513d fix: recent_activity dedup + pagination across MCP tools (#595)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:48:58 -06:00
Drew Cain 9c9ff2931d fix: backend-specific distance-to-similarity conversion (#593)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-02-20 20:15:46 -06:00
Paul Hernandez bbe6c1e8f3 chore: add ty as supplemental type checker (#594)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-20 20:14:46 -06:00
Paul Hernandez edb7991ccf fix: strip NUL bytes from content before PostgreSQL search indexing (#592)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:11:04 -06:00
phernandez d7faeb754e feat: Add destination_folder parameter to move_note tool
Allows callers to move a note into a folder while preserving its original
filename — no need for a separate read_note round-trip to extract the basename.

- destination_folder is mutually exclusive with destination_path
- Rejected for directory moves (is_directory=True)
- Uses Path().name and PureWindowsPath().as_posix() for cross-platform compat
- Validates resolved path against path traversal attacks
- Includes formatting fixes in promo.py and test_analytics.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-20 02:06:04 -06:00
jope-bm deef89a724 feat: Add display_name and is_private to ProjectItem (#574)
Signed-off-by: Joe P <joe@basicmemory.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
Co-authored-by: jope-bm <jope-bm@users.noreply.github.com>
2026-02-20 01:44:07 -06:00
Paul Hernandez 30499a9f61 feat: Let stdio MCP honor per-project cloud routing (#590)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:43:45 -06:00
bm-clawd 1ac65b944c feat: CLI analytics via Umami event collector (#572)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-20 00:09:42 -06:00
Paul Hernandez 306e562281 chore: Make semantic deps default, auto-backfill embeddings, and default search to semantic (#586)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-19 18:31:15 -06:00
phernandez ed82b0c417 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2026-02-18 22:23:53 -06:00
Paul Hernandez 0cb3f95d67 feat: Add JSON output mode for BM MCP tools (default text) (#585)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-02-18 22:18:29 -06:00
phernandez 0a36256f8a Merge branch 'main' of github.com:basicmachines-co/basic-memory 2026-02-18 10:50:58 -06:00
phernandez 36e67e6eec ci: split and speed up PR test matrix
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-18 10:41:02 -06:00
Paul Hernandez f2683291e4 feat: add workspace selection flow for MCP and CLI (#576)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-17 22:48:06 -06:00
phernandez 8c05a9ec80 fix: stabilize semantic search defaults, FTS fallback, and postgres project sync
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-17 15:36:46 -06:00
phernandez a6d8d4c0f6 Add ensure_frontmatter_on_sync with precedence warning
Add a new config option to enforce frontmatter on markdown sync when missing, writing derived title/type/permalink and updating in-memory metadata before upsert. Add startup warning when this option is combined with disable_permalinks to make precedence explicit. Add config/sync/initialization tests for the new behavior, and stabilize project list CLI integration assertions by forcing a wide terminal in tests to avoid Rich truncation.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-16 22:08:09 -06:00
phernandez 0239f4abb4 Simplify local/cloud routing and clarify project targeting
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-16 21:42:25 -06:00
Paul Hernandez 9259a7eb59 feat: min-similarity override, edit-note CLI, and strip-frontmatter (#571)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:00 -06:00
Paul Hernandez 55d675e278 feat: min_similarity override, cloud promo improvements (#570)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:44:02 -06:00
Drew Cain 6afe4fd0cc feat: expose external_id in EntityResponse and link resolver (#569)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:33:37 -06:00
phernandez 113d1b6f1b formatting fix
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-14 09:56:37 -06:00
Paul Hernandez 545804f194 feat: Add project-prefixed permalinks and memory URL routing (#544)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:45:50 -06:00
Paul Hernandez 8bc03d1357 feat(mcp): add MCP UI variants and TUI output (#545)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-13 15:21:48 -06:00
Paul Hernandez f6e0a5b5bb fix: Speed up bm --version startup (#534)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 11:12:52 -06:00
Paul Hernandez 7624a20d8d feat: isolate default sqlite db by config dir (#567)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-13 11:11:13 -06:00
Paul Hernandez d84708ca7f feat: add per-project local/cloud routing with API key auth (#555)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:52:50 -06:00
Paul Hernandez 1428d18de1 fix: make semantic search dependencies optional extras (#566)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:46:26 -06:00
Paul Hernandez 312662f382 feat: Add cloud discovery touchpoints to CLI and MCP (#546)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-12 21:01:45 -06:00
Paul Hernandez ed9487708e feat: enable default_project_mode by default (#560)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:34:46 -06:00
Paul Hernandez 8df88e4d02 feat: add basic-memory watch CLI command (#559)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:10:09 -06:00
Paul Hernandez 07778790d3 feat: add semantic vector search for SQLite and Postgres (#550)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: bm-clawd <clawd@basicmemory.com>
Co-authored-by: bm-clawd <clawd@basicmemory.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:44:47 -06:00
Paul Hernandez b609c4e531 fix: use global --header for Tigris consistency on all rclone transactions (#564)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:43:05 -06:00
Paul Hernandez f1a065bce3 chore: Release/v0.18.2 (#563)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:41:38 -06:00
phernandez 2b94d9a278 fix formatting for tigris headers
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-12 13:22:31 -06:00
Paul Hernandez 344e651693 fix: use VIRTUAL instead of STORED columns in SQLite migration (#562)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 22:24:51 -06:00
Paul Hernandez c97733d785 feat: Schema system for Basic Memory (#549)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:04:42 -06:00
453 changed files with 53331 additions and 5157 deletions
+1 -3
View File
@@ -1,5 +1,3 @@
{
"enabledPlugins": {
"basic-memory@basicmachines": true
}
"enabledPlugins": {}
}
+170 -70
View File
@@ -1,25 +1,70 @@
name: Tests
concurrency:
group: bm-ci-${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
# pull_request_target runs on the BASE of the PR, not the merge result.
# It has write permissions and access to secrets.
# It's useful for PRs from forks or automated PRs but requires careful use for security reasons.
# See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
pull_request_target:
branches: [ "main" ]
jobs:
test-sqlite:
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
static-checks:
name: Static Checks (Python 3.12)
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v4
with:
python-version: "3.12"
cache: "pip"
- name: Install uv
run: |
pip install uv
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
uv venv
- name: Install dependencies
run: |
uv pip install -e ".[dev]"
- name: Run type checks
run: |
just typecheck
- name: Run linting
run: |
just lint
test-sqlite-unit:
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: [ "3.12", "3.13", "3.14" ]
include:
- os: ubuntu-latest
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
- os: ubuntu-latest
python-version: "3.14"
- os: windows-latest
python-version: "3.12"
runs-on: ${{ matrix.os }}
steps:
@@ -37,17 +82,7 @@ jobs:
run: |
pip install uv
- name: Install just (Linux/macOS)
if: runner.os != 'Windows'
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- name: Install just (Windows)
if: runner.os == 'Windows'
run: |
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
choco install just --yes
shell: pwsh
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -55,27 +90,70 @@ jobs:
- name: Install dependencies
run: |
uv pip install -e .[dev]
uv pip install -e ".[dev]"
- name: Run type checks
- name: Run tests
run: |
just typecheck
just test-unit-sqlite
- name: Run linting
run: |
just lint
- name: Run tests (SQLite)
run: |
uv pip install pytest pytest-cov
just test-sqlite
test-postgres:
name: Test Postgres (Python ${{ matrix.python-version }})
test-sqlite-integration:
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
strategy:
fail-fast: false
matrix:
python-version: [ "3.12", "3.13", "3.14" ]
include:
- os: ubuntu-latest
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
- os: ubuntu-latest
python-version: "3.14"
- os: windows-latest
python-version: "3.12"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install uv
run: |
pip install uv
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
uv venv
- name: Install dependencies
run: |
uv pip install -e ".[dev]"
- name: Run tests
run: |
just test-int-sqlite
test-postgres-unit:
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest
# Note: No services section needed - testcontainers handles Postgres in Docker
@@ -95,9 +173,7 @@ jobs:
run: |
pip install uv
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -105,15 +181,60 @@ jobs:
- name: Install dependencies
run: |
uv pip install -e .[dev]
uv pip install -e ".[dev]"
- name: Run tests (Postgres via testcontainers)
- name: Run tests
run: |
uv pip install pytest pytest-cov
just test-postgres
just test-unit-postgres
coverage:
name: Coverage Summary (combined, Python 3.12)
test-postgres-integration:
name: Test Postgres Integration (Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest
# Note: No services section needed - testcontainers handles Postgres in Docker
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install uv
run: |
pip install uv
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
uv venv
- name: Install dependencies
run: |
uv pip install -e ".[dev]"
- name: Run tests
run: |
just test-int-postgres
test-semantic:
name: Test Semantic (Python 3.12)
timeout-minutes: 45
needs: [static-checks]
runs-on: ubuntu-latest
steps:
@@ -131,9 +252,7 @@ jobs:
run: |
pip install uv
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- uses: extractions/setup-just@v3
- name: Create virtual env
run: |
@@ -141,27 +260,8 @@ jobs:
- name: Install dependencies
run: |
uv pip install -e .[dev]
uv pip install -e ".[dev]"
- name: Run combined coverage (SQLite + Postgres)
- name: Run tests
run: |
uv pip install pytest pytest-cov
just coverage
- name: Add coverage report to job summary
if: always()
run: |
{
echo "## Coverage"
echo ""
echo '```'
uv run coverage report -m
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload HTML coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: htmlcov
path: htmlcov/
just test-semantic
+1
View File
@@ -57,3 +57,4 @@ claude-output
.mcp.json
.mcpregistry_*
/.testmondata
.benchmarks/
+46 -8
View File
@@ -31,6 +31,7 @@ See the [README.md](README.md) file for a project overview.
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
- Lint: `just lint` or `ruff check . --fix`
- Type check: `just typecheck` or `uv run pyright`
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
- Format: `just format` or `uv run ruff format .`
- Run all code checks: `just check` (runs lint, format, typecheck, test)
- Create db migration: `just migration "Your migration message"`
@@ -189,27 +190,46 @@ Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Reposito
### Async Client Pattern (Important!)
**All MCP tools and CLI commands use the context manager pattern for HTTP clients:**
**MCP tools use `get_project_client()` for per-project routing:**
```python
from basic_memory.mcp.project_context import get_project_client
@mcp.tool()
async def my_tool(project: str | None = None, context: Context | None = None):
async with get_project_client(project, context) as (client, active_project):
# client is routed based on project's mode (local ASGI or cloud HTTP)
response = await call_get(client, "/path")
return response
```
**CLI commands and non-project-scoped code use `get_client()` directly:**
```python
from basic_memory.mcp.async_client import get_client
async def my_mcp_tool():
async def my_cli_command():
async with get_client() as client:
# Use client for API calls
response = await call_get(client, "/path")
return response
# Per-project routing (when project name is known):
async with get_client(project_name="research") as client:
...
```
**Do NOT use:**
-`from basic_memory.mcp.async_client import client` (deprecated module-level client)
- ❌ Manual auth header management
-`inject_auth_header()` (deleted)
- ❌ Separate `get_client()` + `get_active_project()` in MCP tools (use `get_project_client()` instead)
**Key principles:**
- Auth happens at client creation, not per-request
- Proper resource management via context managers
- Supports three modes: Local (ASGI), CLI cloud (HTTP + auth), Cloud app (factory injection)
- Per-project routing: each project can be LOCAL or CLOUD independently
- Cloud projects use API key (`cloud_api_key` in config) as Bearer token
- Routing priority: factory injection > force-local > per-project cloud > global cloud > local ASGI
- Factory pattern enables dependency injection for cloud consolidation
**For cloud app integration:**
@@ -250,15 +270,19 @@ See SPEC-16 for full context manager refactor details.
- List projects: `basic-memory project list`
- Add project: `basic-memory project add "name" ~/path`
- Project info: `basic-memory project info`
- Set cloud mode: `basic-memory project set-cloud "name"`
- Set local mode: `basic-memory project set-local "name"`
- One-way sync (local -> cloud): `basic-memory project sync`
- Bidirectional sync: `basic-memory project bisync`
- Integrity check: `basic-memory project check`
**Cloud Commands (requires subscription):**
- Authenticate: `basic-memory cloud login`
- Logout: `basic-memory cloud logout`
- Authenticate (global): `basic-memory cloud login`
- Logout (global): `basic-memory cloud logout`
- Check cloud status: `basic-memory cloud status`
- Setup cloud sync: `basic-memory cloud setup`
- Save API key: `basic-memory cloud set-key bmc_...`
- Create API key: `basic-memory cloud create-key "name"`
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
@@ -329,9 +353,22 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
- Background relation resolution (non-blocking startup)
- API performance optimizations (SPEC-11)
**CLI Routing Flags:**
**Per-Project Cloud Routing:**
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
Individual projects can be routed through the cloud while others stay local, using an API key:
```bash
# Save API key and set project to cloud mode
basic-memory cloud set-key bmc_abc123...
basic-memory project set-cloud research # route through cloud
basic-memory project set-local research # revert to local
```
MCP tools use `get_project_client()` which automatically routes based on the project's mode. Cloud projects use the `cloud_api_key` from config as Bearer token.
**CLI Routing Flags (Global Cloud Mode):**
When global cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
```bash
# Force local routing (ignore cloud mode)
@@ -348,6 +385,7 @@ Key behaviors:
- This allows simultaneous use of local Claude Desktop and cloud-based clients
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
- Per-project cloud routing via API key works independently of global cloud mode
## AI-Human Collaborative Development
+172
View File
@@ -1,5 +1,177 @@
# CHANGELOG
## Unreleased
## v0.19.2 (2026-03-09)
### Bug Fixes
- **#657**: Coerce string params to list/dict in MCP tools
- MCP clients that serialize `list`/`dict` arguments as JSON strings no longer fail Pydantic validation
- Adds `BeforeValidator` coercion to `search_notes` (`entity_types`, `note_types`, `tags`, `metadata_filters`), `write_note` (`metadata`), and `canvas` (`nodes`, `edges`)
- **#655**: Handle SQLite and Windows semantic search regressions
- Fix embedding status query for non-semantic SQLite databases
- Windows-safe log file rotation with per-process log filenames
- Robust `setup_logging` that handles all environments cleanly
## v0.19.1 (2026-03-08)
### Bug Fixes
- **#649**: Enforce strict entity resolution in destructive MCP tools (`edit_note`, `move_note`, `delete_note`)
- Prevents fuzzy-match fallback from silently editing/moving/deleting the wrong note
- DST-related timeframe validation fix (round instead of truncate days)
### Features
- **#648**: Add `insert_before_section` and `insert_after_section` edit operations
- Add `GET /knowledge/graph` endpoint for full graph visualization
### Dependencies
- Bump authlib from 1.6.6 to 1.6.7
## v0.19.0 (2026-03-07)
### Highlights
- **Semantic vector search** for SQLite and Postgres with FastEmbed embeddings
- **Schema system** for validating and inferring knowledge base structure
- **Per-project cloud routing** with API key authentication
- **Upgraded to FastMCP 3.0** with tool annotations
- **CLI overhaul** with JSON output, workspace awareness, and project dashboard
### Features
- **#550**: Add semantic vector search for SQLite and Postgres
- FastEmbed-based embeddings with automatic backfill
- Hybrid search combining full-text and vector similarity
- Score-based fusion replacing RRF for better ranking
- `min_similarity` override for tuning search precision
- Semantic dependencies are now default, with optional extras fallback
- **#549**: Schema system for Basic Memory
- `schema_infer` — infer schema from existing notes
- `schema_validate` — validate notes against a schema definition
- `schema_diff` — compare schemas across projects
- Frontmatter validation support (#597)
- Read schema definitions from file instead of stale DB metadata (#635)
- **#555**: Per-project local/cloud routing with API key auth
- Individual projects route through cloud while others stay local
- `basic-memory cloud set-key` and `basic-memory project set-cloud/set-local`
- Stdio MCP honors per-project cloud routing (#590)
- **#598**: Upgrade FastMCP 2.12.3 to 3.0.1 with tool annotations
- **#585**: Add JSON output mode for MCP tools (default text)
- `--json` output for CLI commands for scripting and CI
- **#576**: Add workspace selection flow for MCP and CLI
- Workspace-aware cloud project listing
- CLI refactoring for workspace support
- **#544**: Project-prefixed permalinks and memory URL routing
- **#632**: Add overwrite guard to `write_note` tool
- **#614**: `edit_note` append/prepend auto-creates note if not found
- **#609**: Richer content context in search results
- Return matched chunk text in search results (#601)
- Improved content hit rate
- **#602**: Add `created_by` and `last_updated_by` user tracking to Entity
- **#600**: Rename `entity_type` to `note_type` across codebase
- **#574**: Add `display_name` and `is_private` to ProjectItem
- **#569**: Expose `external_id` in EntityResponse and link resolver
- **#567**: Isolate default SQLite DB by config dir
- **#560**: Enable `default_project_mode` by default
- **#559**: Add `basic-memory watch` CLI command
- **#546**: Add cloud discovery touchpoints to CLI and MCP
- **#572**: CLI analytics via Umami event collector
- Replace project info with htop-inspired dashboard
- Merge `search_by_metadata` into `search_notes` with optional query
- Add `--strip-frontmatter` to `basic-memory tool read-note`
- Add `destination_folder` parameter to `move_note` tool
### Bug Fixes
- **#644**: Fix default project resolution in cloud mode
- ChatGPT search/fetch tools broken in cloud mode
- `resolve_project_parameter` falls back to projects API
- **#638**: Restore API backward compatibility for v0.18.x clients
- **#637**: Create backup before config migration overwrites old format
- **#636**: `list_workspaces` bypasses factory pattern on cloud MCP server
- **#631**: `build_context` related_results schema validation failure
- **#613**: Reduce excessive log volume by demoting per-request noise to DEBUG
- **#612**: Handle quoted picoschema enum strings in YAML frontmatter
- **#607**: Guard against closed streams in promo and missing vector tables
- **#606**: Accept null for `expected_replacements` in `edit_note`
- **#595**: `recent_activity` dedup and pagination across MCP tools
- **#593**: Backend-specific distance-to-similarity conversion
- **#582**: Use LinkResolver fallback in `build_context` for flexible identifier matching
- **#577**: Replace RRF with score-based fusion in hybrid search
- **#575**: Remove hardcoded "main" default from `default_project`
- **#534**: Speed up `bm --version` startup
- Fix semantic embeddings not generated on fresh DB or upgrade
- Clarify `search_notes` parameter naming and fix `note_types` case sensitivity
- Parse `tag:` prefix at MCP tool level to avoid hybrid search failure
- Cap sqlite-vec knn k parameter at 4096 limit
- Parameterize SQL queries in search repository type filters
- Coerce list frontmatter values to strings for title and type fields
- Avoid `Post(**metadata)` crash when frontmatter contains 'content' or 'handler' keys
- Upgrade cryptography and python-multipart for security advisories
### Internal
- **#594**: Add `ty` as supplemental type checker
- Batched vector sync orchestration across repositories
- FastEmbed parallel guardrails and provider caching
- Improved cloud CLI status and error messages
- CI coverage and Postgres test fixes
## v0.18.5 (2026-02-13)
### Bug Fixes
- Strip NUL bytes from content before PostgreSQL search indexing
([`ec9b2c4`](https://github.com/basicmachines-co/basic-memory/commit/ec9b2c4))
## v0.18.4 (2026-02-12)
### Bug Fixes
+494
View File
@@ -0,0 +1,494 @@
# Note Format Reference
Every document in Basic Memory is a plain Markdown file. Files are the source of truth — changes to files automatically update the knowledge graph in the database. You maintain complete ownership, files work with git, and knowledge persists independently of any AI conversation.
## Document Structure
A note has three parts: YAML frontmatter, content (observations), and relations.
```markdown
---
title: Coffee Brewing Methods
type: note
tags: [coffee, brewing]
permalink: coffee-brewing-methods
---
# Coffee Brewing Methods
## Observations
- [method] Pour over provides more flavor clarity than French press
- [technique] Water temperature at 205°F extracts optimal compounds #brewing
- [preference] Ethiopian beans work well with lighter roasts (personal experience)
## Relations
- relates_to [[Coffee Bean Origins]]
- requires [[Proper Grinding Technique]]
- contrasts_with [[Tea Brewing Methods]]
```
The `## Observations` and `## Relations` headings are conventional but not required — the parser detects observations and relations by their syntax patterns anywhere in the document.
## Frontmatter
YAML metadata between `---` fences at the top of the file.
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `title` | No | filename stem | Used for linking and references. Auto-set from filename if missing. |
| `type` | No | `note` | Entity type. Used for schema resolution and filtering. |
| `tags` | No | `[]` | List or comma-separated string. Used for organization and search. |
| `permalink` | No | generated from title | Stable identifier. Persists even if the file moves. |
| `schema` | No | none | Schema attachment — dict (inline), string (reference), or omitted (implicit). |
Custom fields are allowed. Any key not in the standard set is stored as `entity_metadata` and indexed for search and filtering.
```yaml
---
title: Paul Graham
type: Person
tags: [startups, essays, lisp]
permalink: paul-graham
status: active
source: wikipedia
---
```
Here `status` and `source` are custom fields stored in `entity_metadata`.
### Frontmatter Value Handling
YAML automatically converts some values to native types. Basic Memory normalizes them:
- Date strings (`2025-10-24`) → kept as ISO format strings
- Numbers (`1.0`) → converted to strings
- Booleans (`true`) → converted to strings (`"True"`)
- Lists and dicts → preserved, items normalized recursively
This prevents errors when downstream code expects string values.
## Observations
An observation is a categorized fact about the entity. Written as a Markdown list item.
**Syntax:**
```
- [category] content text #tag1 #tag2 (context)
```
| Part | Required | Description |
|------|----------|-------------|
| `[category]` | Yes | Classification in square brackets. Any text except `[]()` chars. |
| content | Yes | The fact or statement. |
| `#tags` | No | Inline tags. Space-separated, each starting with `#`. |
| `(context)` | No | Parenthesized text at end of line. Supporting details or source. |
### Examples
```markdown
- [tech] Uses SQLite for storage #database
- [design] Follows local-first architecture #architecture
- [decision] Selected bcrypt for passwords #security (based on OWASP audit)
- [name] Paul Graham
- [expertise] Startups
- [expertise] Lisp
- [expertise] Essay writing
```
Array-like fields use repeated categories — multiple `[expertise]` observations above.
### What Is Not an Observation
The parser excludes these list item patterns:
| Pattern | Example | Reason |
|---------|---------|--------|
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
| Markdown links | `- [text](url)` | URL link syntax |
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
## Relations
Relations connect documents to form the knowledge graph. There are two kinds.
### Explicit Relations
Written as list items with a relation type and a `[[wiki link]]` target.
**Syntax:**
```
- relation_type [[Target Entity]] (context)
```
| Part | Required | Description |
|------|----------|-------------|
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
### Examples
```markdown
- implements [[Search Design]]
- depends_on [[Database Schema]]
- works_at [[Y Combinator]] (co-founder)
- [[Some Entity]]
```
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
Common relation types:
- `implements`, `depends_on`, `relates_to`, `inspired_by`
- `extends`, `part_of`, `contains`, `pairs_with`
- `works_at`, `authored`, `collaborated_with`
Any text works as a relation type. These are conventions, not a fixed set.
### Inline References
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
```markdown
This builds on [[Core Design]] and uses [[Utility Functions]].
```
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
### Forward References
Relations can link to entities that don't exist yet. Basic Memory resolves them when the target is created.
## Permalinks and memory:// URLs
Every document has a unique **permalink** — a stable identifier derived from its title. You can set one explicitly in frontmatter, or let the system generate it.
```yaml
permalink: auth-approaches-2024
```
Permalinks form the basis of `memory://` URLs:
```
memory://auth-approaches-2024 # By permalink
memory://Authentication Approaches # By title (auto-resolves)
memory://project/auth-approaches # By path
```
Pattern matching is supported:
```
memory://auth* # Starts with "auth"
memory://*/approaches # Ends with "approaches"
memory://project/*/requirements # Nested wildcard
```
## Schemas
Schemas declare the expected structure of a note — which observation categories and relation types a well-formed note should have. They use Picoschema, a compact notation from Google's Dotprompt that fits naturally in YAML frontmatter.
### Picoschema Syntax
```yaml
schema:
name: string, full name # required field with description
email?: string, contact email # ? = optional
role?: string, job title
works_at?: Organization, employer # capitalized type = entity reference
tags?(array): string, categories # array of type
status?(enum): [active, inactive] # enum with allowed values
metadata?(object): # nested object
updated_at?: string
source?: string
```
| Notation | Meaning | Example |
|----------|---------|---------|
| `field: type` | Required field | `name: string` |
| `field?: type` | Optional field | `role?: string` |
| `field(array): type` | Array of values | `expertise(array): string` |
| `field?(enum): [vals]` | Enum with allowed values | `status?(enum): [active, inactive]` |
| `field?(object):` | Nested object with sub-fields | `metadata?(object):` |
| `, description` | Description after comma | `name: string, full name` |
| `EntityName` | Capitalized type = entity reference | `works_at?: Organization` |
**Scalar types:** `string`, `integer`, `number`, `boolean`, `any`
Any type not in that set whose first letter is uppercase is treated as an entity reference (a relation target).
### Schema-to-Note Mapping
Schemas validate against existing observation/relation syntax. Note authors don't learn new syntax.
| Schema Declaration | Maps To | Example in Note |
|--------------------|---------|-----------------|
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (repeated) |
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (repeated) |
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
| `field?(enum): [vals]` | Observation `[field] value` where value is in the set | `- [status] active` |
Observations and relations not covered by the schema are valid — schemas describe a subset, not a straitjacket.
### Schema Attachment
Three ways to attach a schema to a note, resolved in priority order:
**1. Inline schema**`schema` is a dict in frontmatter:
```yaml
---
title: Team Standup 2024-01-15
type: meeting
schema:
attendees(array): string, who was there
decisions(array): string, what was decided
action_items(array): string, follow-ups
blockers?(array): string, anything stuck
---
```
Good for one-off structured notes or prototyping a schema before extracting it.
**2. Explicit reference**`schema` is a string naming a schema note:
```yaml
---
title: Basic Memory
schema: SoftwareProject
---
```
or by permalink:
```yaml
---
title: LLM Memory Patterns
schema: schema/research-project
---
```
Use when the note's `type` differs from the schema it should validate against, or when multiple schema variants exist.
**3. Implicit by type** — no `schema` field, resolved by matching `type`:
```yaml
---
title: Paul Graham
type: Person
---
```
The system looks up a schema note where `entity: Person`. If found, it applies. If not, no validation occurs.
**4. No schema** — perfectly fine. Most notes don't need one.
### Schema Notes
A schema is itself a Basic Memory note with `type: schema`. It lives anywhere (though `schema/` is the conventional directory).
```yaml
# schema/Person.md
---
title: Person
type: schema
entity: Person
version: 1
schema:
name: string, full name
role?: string, job title or position
works_at?: Organization, employer
expertise?(array): string, areas of knowledge
email?: string, contact email
settings:
validation: warn
---
# Person
A human individual in the knowledge graph.
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | Yes | Must be `schema` |
| `entity` | Yes | The entity type this schema describes (e.g., `Person`) |
| `version` | No | Schema version number (default: `1`) |
| `schema` | Yes | Picoschema dict defining the fields |
| `settings.validation` | No | Validation mode (default: `warn`) |
Schema notes are regular notes — they show up in search, can have observations and relations, and participate in the knowledge graph.
### Validation Modes
| Mode | Behavior |
|------|----------|
| `warn` | Warnings in output, doesn't block (default) |
| `strict` | Errors that block sync, for CI/CD enforcement |
| `off` | No validation |
### Validation Output
```
$ bm schema validate people/ada-lovelace.md
⚠ Person schema validation:
- Missing required field: name (expected [name] observation)
- Missing optional field: role
- Missing optional field: works_at (no relation found)
Unmatched observations: [fact] ×2, [born] ×1
Unmatched relations: collaborated_with
```
"Unmatched" items are informational — observations and relations the schema doesn't cover.
### Schema Inference
Generate schemas from existing notes by analyzing observation and relation frequency:
```
$ bm schema infer Person
Analyzing 30 notes with type: Person...
Observations found:
[name] 30/30 100% → name: string
[role] 27/30 90% → role?: string
[expertise] 18/30 60% → expertise?(array): string
[email] 8/30 27% → email?: string
Relations found:
works_at 22/30 73% → works_at?: Organization
Suggested schema:
name: string, full name
role?: string, job title
expertise?(array): string, areas of knowledge
email?: string, contact email
works_at?: Organization, employer
Save to schema/Person.md? [y/n]
```
Frequency thresholds:
- **100% present** → required field
- **25%+ present** → optional field
- **Below 25%** → excluded from suggestion
### Schema Drift Detection
Track how usage patterns shift over time:
```
$ bm schema diff Person
Schema drift detected:
+ expertise: now in 81% of notes (was 12%)
- department: dropped to 3% of notes
~ works_at: cardinality changed (one → many)
Update schema? [y/n/review]
```
## Complete Examples
### Simple Note (No Schema)
```markdown
---
title: Project Ideas
type: note
tags: [ideas, brainstorm]
---
# Project Ideas
## Observations
- [idea] Build a CLI tool for markdown linting #tooling
- [idea] Create a recipe knowledge base #cooking
- [priority] Focus on developer tools first (Q1 goal)
## Relations
- inspired_by [[Developer Workflow Research]]
- part_of [[Q1 Planning]]
```
### Schema-Validated Note
Schema at `schema/Person.md`:
```yaml
---
title: Person
type: schema
entity: Person
version: 1
schema:
name: string, full name
role?: string, job title or position
works_at?: Organization, employer
expertise?(array): string, areas of knowledge
email?: string, contact email
settings:
validation: warn
---
# Person
A human individual in the knowledge graph.
```
Note at `people/paul-graham.md`:
```markdown
---
title: Paul Graham
type: Person
tags: [startups, essays, lisp]
---
# Paul Graham
## Observations
- [name] Paul Graham
- [role] Essayist and investor
- [expertise] Startups
- [expertise] Lisp
- [expertise] Essay writing
- [fact] Created Viaweb, the first web app
## Relations
- works_at [[Y Combinator]]
- authored [[Hackers and Painters]]
```
The `[fact]` observation and `authored` relation are not in the schema — they're valid, just unmatched. The schema only checks that `[name]` exists (required) and looks for optional fields like `[role]`, `[expertise]`, and `works_at`.
### Inline Schema Note
```markdown
---
title: Team Standup 2024-01-15
type: meeting
schema:
attendees(array): string, who was there
decisions(array): string, what was decided
action_items(array): string, follow-ups
blockers?(array): string, anything stuck
---
# Team Standup 2024-01-15
## Observations
- [attendees] Paul
- [attendees] Sarah
- [decisions] Ship v2 by Friday
- [action_items] Paul to review PR #42
- [blockers] Waiting on API credentials
```
+139 -42
View File
@@ -9,11 +9,11 @@
## 🚀 Basic Memory Cloud is Live!
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile - seamlessly synced across all your AI tools (Claude, ChatGPT, Gemini, Claude Code, and Codex)
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile.
- **Cloud is optional.** The local-first open-source workflow continues as always.
- **OSS discount:** use code `BMFOSS` for 20% off for 3 months.
[Sign up now →](https://basicmemory.com)
[Sign up now →](https://basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
with a 7 day free trial
@@ -23,8 +23,21 @@ Basic Memory lets you build persistent knowledge through natural conversations w
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
enable any compatible LLM to read and write to your local knowledge base.
- Website: https://basicmemory.com
- Documentation: https://docs.basicmemory.com
## What's New in v0.19.0
- **Semantic Vector Search** — find notes by meaning, not just keywords. Combines full-text and vector similarity for hybrid search with FastEmbed embeddings.
- **Schema System** — infer, validate, and diff the structure of your knowledge base with `schema_infer`, `schema_validate`, and `schema_diff` tools.
- **Per-Project Cloud Routing** — route individual projects through the cloud while others stay local, using API key authentication (`basic-memory project set-cloud`).
- **FastMCP 3.0** — upgraded to FastMCP 3.0 with tool annotations for better client integration.
- **CLI Overhaul** — JSON output mode (`--json`) for scripting, workspace-aware commands, and an htop-inspired project dashboard.
- **Smarter Editing** — `edit_note` append/prepend auto-creates notes if they don't exist; `write_note` has an overwrite guard to prevent accidental data loss.
- **Richer Search Results** — matched chunk text returned in search results for better context.
See the full [CHANGELOG](CHANGELOG.md) for details.
- Website: [basicmemory.com](https://basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
- Documentation: [docs.basicmemory.com](https://docs.basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
- Community: [Discord](https://discord.gg/tyvKNccgqN?utm_source=github&utm_medium=referral&utm_campaign=readme)
## Pick up your conversation right where you left off
@@ -344,70 +357,120 @@ basic-memory sync --watch
3. Cloud features (optional, requires subscription):
```bash
# Authenticate with cloud
# Authenticate with cloud (stores OAuth token locally)
basic-memory cloud login
# Bidirectional sync with cloud
basic-memory cloud sync
# (Optional) install/configure rclone for file sync commands
basic-memory cloud setup
# Verify cloud integrity
basic-memory cloud check
# Mount cloud storage
basic-memory cloud mount
# Check cloud auth + health
basic-memory cloud status
```
**Routing Flags** (for users with cloud subscriptions):
**Per-Project Cloud Routing** (API key based):
When cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
Individual projects can be routed through the cloud while others stay local. This uses an API key for routed
project calls:
```bash
# Force local routing (useful for local MCP server while cloud mode is enabled)
basic-memory status --local
basic-memory project list --local
# Save an API key (create one in the web app or via CLI)
basic-memory cloud set-key bmc_abc123...
# Or create one via CLI (requires OAuth login first)
basic-memory cloud create-key "my-laptop"
# Force cloud routing (when cloud mode is disabled but you want cloud access)
basic-memory status --cloud
basic-memory project info my-project --cloud
# Set a project to route through cloud
basic-memory project set-cloud research
# Revert a project to local mode
basic-memory project set-local research
# List projects and route metadata
basic-memory project list
```
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
`basic-memory cloud login` / `basic-memory cloud logout` are authentication commands. They do not change default CLI
routing behavior.
**Routing Flags**:
Use routing flags to disambiguate command targets:
```bash
# Force local routing for this command
basic-memory status --local
basic-memory project list --local
basic-memory project ls --name main --local
# Force cloud routing for this command
basic-memory status --cloud
basic-memory project info my-project --cloud
basic-memory project ls --name main --cloud
```
No-flag behavior defaults to local when no project context is present.
The local MCP server routes per transport: `--transport stdio` honors per-project routing
(local or cloud), while `--transport streamable-http` and `--transport sse` always route locally.
**CLI Note Editing (`tool edit-note`):**
```bash
# Append content
basic-memory tool edit-note project-plan --operation append --content $'\n## Next Steps\n- Finalize rollout'
# Find/replace with replacement count validation
basic-memory tool edit-note docs/api --operation find_replace --find-text "v0.14.0" --content "v0.15.0" --expected-replacements 2
# Replace a section body
basic-memory tool edit-note docs/setup --operation replace_section --section "## Installation" --content $'Updated install steps\n- Run just install'
# JSON metadata output for integrations
basic-memory tool edit-note docs/setup --operation append --content $'\n- Added note' --format json
```
4. In Claude Desktop, the LLM can now use these tools:
**Content Management:**
```
write_note(title, content, folder, tags) - Create or update notes
read_note(identifier, page, page_size) - Read notes by title or permalink
write_note(title, content, folder, tags, output_format="text"|"json") - Create or update notes
read_note(identifier, page, page_size, output_format="text"|"json") - Read notes by title or permalink
read_content(path) - Read raw file content (text, images, binaries)
view_note(identifier) - View notes as formatted artifacts
edit_note(identifier, operation, content) - Edit notes incrementally
move_note(identifier, destination_path) - Move notes with database consistency
delete_note(identifier) - Delete notes from knowledge base
edit_note(identifier, operation, content, output_format="text"|"json") - Edit notes incrementally
move_note(identifier, destination_path, output_format="text"|"json") - Move notes with database consistency
delete_note(identifier, output_format="text"|"json") - Delete notes from knowledge base
```
**Knowledge Graph Navigation:**
```
build_context(url, depth, timeframe) - Navigate knowledge graph via memory:// URLs
recent_activity(type, depth, timeframe) - Find recently updated information
build_context(url, depth, timeframe, output_format="json"|"text") - Navigate knowledge graph via memory:// URLs
recent_activity(type, depth, timeframe, output_format="text"|"json") - Find recently updated information
list_directory(dir_name, depth) - Browse directory contents with filtering
```
**Search & Discovery:**
```
search(query, page, page_size) - Search across your knowledge base
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters
search_by_metadata(filters, limit, offset, project) - Structured frontmatter search
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters (query is optional for filter-only searches)
```
**Project Management:**
```
list_memory_projects() - List all available projects
create_memory_project(project_name, project_path) - Create new projects
list_memory_projects(output_format="text"|"json") - List all available projects
create_memory_project(project_name, project_path, output_format="text"|"json") - Create new projects
get_current_project() - Show current project stats
sync_status() - Check synchronization status
```
`output_format` defaults to `"text"` for these tools, preserving current human-readable responses.
`build_context` defaults to `"json"` and can be switched to `"text"` when compact markdown output is preferred.
**Cloud Discovery (opt-in):**
```
cloud_info() - Show optional Cloud overview and setup guidance
release_notes() - Show latest release notes
```
**Visualization:**
```
canvas(nodes, edges, title, folder) - Generate knowledge visualizations
@@ -425,13 +488,38 @@ canvas(nodes, edges, title, folder) - Generate knowledge visualizations
## Futher info
See the [Documentation](https://docs.basicmemory.com) for more info, including:
See the [Documentation](https://docs.basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme) for more info, including:
- [Complete User Guide](https://docs.basicmemory.com/user-guide/)
- [CLI tools](https://docs.basicmemory.com/guides/cli-reference/)
- [Cloud CLI and Sync](https://docs.basicmemory.com/guides/cloud-cli/)
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
- [Complete User Guide](https://docs.basicmemory.com/user-guide/?utm_source=github&utm_medium=referral&utm_campaign=readme)
- [CLI tools](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme)
- [Cloud CLI and Sync](https://docs.basicmemory.com/guides/cloud-cli/?utm_source=github&utm_medium=referral&utm_campaign=readme)
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme#project)
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme#import)
## Telemetry
Basic Memory collects anonymous, minimal usage events to understand how the CLI-to-cloud conversion funnel performs. This helps us prioritize features and improve the product.
**What we collect:**
- Cloud promo impressions (when the promo banner is shown)
- Cloud login attempts and outcomes
- Promo opt-out events
**What we do NOT collect:**
- No file contents, note titles, or knowledge base data
- No personally identifiable information (PII)
- No IP address tracking or fingerprinting
- No per-command or per-tool-call tracking
Events are sent to our [Umami Cloud](https://umami.is) instance, an open-source, privacy-focused analytics platform. Events are fire-and-forget on a background thread — analytics never blocks or slows the CLI.
**Opt out** by setting the environment variable:
```bash
export BASIC_MEMORY_NO_PROMOS=1
```
This disables both promo messages and all telemetry events.
## Logging
@@ -451,8 +539,11 @@ Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The lo
|----------|---------|-------------|
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) |
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing |
| `BASIC_MEMORY_FORCE_CLOUD` | `false` | When `true`, forces cloud API routing |
| `BASIC_MEMORY_EXPLICIT_ROUTING` | `false` | When `true`, marks route selection as explicit (`--local`/`--cloud`) |
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
| `BASIC_MEMORY_NO_PROMOS` | `false` | When `true`, disables cloud promo messages and telemetry |
### Examples
@@ -519,6 +610,7 @@ Tests use pytest markers for selective execution:
just install # Install with dev dependencies
just lint # Run linting checks
just typecheck # Run type checking
just typecheck-ty # Run ty type checking (incremental supplement to pyright)
just format # Format code with ruff
just fast-check # Fast local loop (fix/format/typecheck + testmon + smoke)
just doctor # Local consistency check (temp config)
@@ -526,6 +618,11 @@ just check # Run all quality checks
just migration "msg" # Create database migration
```
**Type Checking Strategy:**
- `just typecheck` (Pyright) remains the primary, blocking type checker.
- `just typecheck-ty` (Astral `ty`) is available as a supplemental checker while rules are adopted incrementally.
- We recommend running both locally while reducing `ty` diagnostics over time.
**Local Consistency Check:**
```bash
basic-memory doctor # Verifies file <-> database sync in a temp project
@@ -550,4 +647,4 @@ and submitting PRs.
</picture>
</a>
Built with ♥️ by Basic Machines
Built with ♥️ by [Basic Machines](https://basicmachines.co?utm_source=github&utm_medium=referral&utm_campaign=readme)
+29 -12
View File
@@ -18,7 +18,7 @@ Each entrypoint uses a **composition root** pattern to manage configuration and
A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that:
1. Reads configuration from `ConfigManager`
2. Resolves runtime mode (cloud/local/test)
2. Resolves runtime mode (local/test)
3. Creates and provides dependencies to downstream code
**Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly.
@@ -52,10 +52,7 @@ class Container:
def create(cls) -> "Container":
"""Create container by reading ConfigManager."""
config = ConfigManager().config
mode = resolve_runtime_mode(
cloud_mode_enabled=config.cloud_mode_enabled,
is_test_env=config.is_test_env,
)
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
return cls(config=config, mode=mode)
@property
@@ -99,17 +96,20 @@ class RuntimeMode(Enum):
return self == RuntimeMode.TEST
```
Resolution follows this precedence: **TEST > CLOUD > LOCAL**
Resolution follows this precedence in local app flows: **TEST > LOCAL**
```python
def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> RuntimeMode:
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
if is_test_env:
return RuntimeMode.TEST
if cloud_mode_enabled:
return RuntimeMode.CLOUD
return RuntimeMode.LOCAL
```
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync).
Per-project routing is orthogonal: individual projects can be set to `cloud` mode via `ProjectMode`,
which affects client routing in `get_client(project_name=...)` without changing global runtime mode.
`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it.
## Dependencies Package
### Structure
@@ -221,9 +221,7 @@ async def search_notes(
tags: list[str] | None = None,
status: str | None = None,
) -> SearchResponse:
async with get_client() as client:
active_project = await get_active_project(client, project)
async with get_project_client(project, context) as (client, active_project):
# Import client inside function to avoid circular imports
from basic_memory.mcp.clients import SearchClient
from basic_memory.schemas.search import SearchQuery
@@ -238,6 +236,25 @@ async def search_notes(
return await search_client.search(search_query.model_dump())
```
### Per-Project Client Routing
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
1. Resolves the project name from config (no network call)
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
3. Validates the project via the API
4. Yields `(client, active_project)` tuple
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
```python
from basic_memory.mcp.project_context import get_project_client
async with get_project_client(project, context) as (client, active_project):
# client is routed based on project's mode (local or cloud)
# active_project is validated via the API
...
```
## Sync Coordination
### SyncCoordinator
+147
View File
@@ -0,0 +1,147 @@
# Simplified Local/Cloud Routing
## Context
Basic Memory now uses explicit, project-aware routing without a global cloud-mode toggle.
Routing is determined by command-level flags and project mode, not by a global `cloud_mode` state.
This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients.
## Goals
1. Remove global `cloud_mode` from runtime/routing semantics.
2. Keep MCP HTTP/SSE local-only; let stdio honor per-project routing.
3. Make CLI routing explicit and easy to reason about.
4. Support projects that exist in both local and cloud without ambiguity.
## Routing Contract
Routing is resolved in this order:
1. Injected client factory (for composition/integration contexts)
2. Explicit routing override (`--local` / `--cloud` or env vars below)
3. Project-scoped routing (`project.mode`) when a project is known
4. Default local routing
### Routing Environment Variables
- `BASIC_MEMORY_FORCE_LOCAL=true`: force local transport
- `BASIC_MEMORY_FORCE_CLOUD=true`: force cloud proxy transport
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`: marks routing as explicitly chosen for this command
When explicit routing is active, project mode does not override the selected route.
## Config Semantics
- `project.mode` is the only config-based routing signal for project-scoped operations.
- Legacy `cloud_mode` values may be encountered during migration/loading but are not used for routing behavior.
- Normalization saves remove stale `cloud_mode` from `~/.basic-memory/config.json`.
### Example Config
```json
{
"projects": {
"main": {
"path": "/Users/me/basic-memory",
"mode": "local",
"local_sync_path": null,
"bisync_initialized": false,
"last_sync": null
},
"specs": {
"path": "specs",
"mode": "cloud",
"local_sync_path": "/Users/me/dev/specs",
"bisync_initialized": true,
"last_sync": "2026-02-06T17:36:38.544153"
}
},
"default_project": "main",
"cloud_api_key": "bmc_abc123...",
"cloud_host": "https://cloud.basicmemory.com"
}
```
## Cloud Commands Are Auth-Only
`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state.
- `bm cloud login`
- performs OAuth device flow
- stores/refreshes token material
- may verify cloud health/subscription
- does not change routing defaults
- `bm cloud logout`
- removes stored OAuth session tokens
- does not change routing defaults
- `bm cloud status`
- reports auth state (API key, OAuth token validity)
- runs health checks only when credentials are available
## MCP Transport Routing
### Stdio (default)
`bm mcp --transport stdio` uses natural per-project routing.
- Local-mode projects route through the in-process ASGI transport.
- Cloud-mode projects route to the cloud proxy with Bearer auth (API key).
- No explicit routing env vars are injected by the CLI command.
- Externally-set env vars are honored (e.g. `BASIC_MEMORY_FORCE_CLOUD=true` for cloud deployments).
- Users who need all projects forced local can set `BASIC_MEMORY_FORCE_LOCAL=true` externally.
### HTTP and SSE Transports
`bm mcp --transport streamable-http` and `bm mcp --transport sse` always route locally.
These transports set explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud
routing regardless of project mode, since HTTP/SSE serve as local API endpoints.
## Project List UX for Dual Presence
Projects may exist in both local and cloud. `bm project list` should display that clearly in one row per logical
project identity, with explicit source/target signals.
Recommended display contract:
1. Keep one row per normalized project name/permalink.
2. Show both local and cloud presence as separate columns/indicators.
3. Show an explicit `MCP (stdio)` target column that always resolves to `local`.
4. Keep CLI route semantics explicit:
- no flags: default local for non-project commands
- `--cloud`: force cloud
- `--local`: force local
## Project LS Targeting
`bm project ls` should clearly identify which project instance is being listed.
Targeting rules:
1. No routing flags: list local project files.
2. `--cloud`: list cloud project files.
3. `--local`: list local project files (explicit override).
4. Output should label the active target (`LOCAL` or `CLOUD`) in heading or status line.
## Runtime Mode
Runtime mode is no longer a cloud/local routing switch for local app flows.
- `resolve_runtime_mode(is_test_env)` resolves to:
- `TEST` when running in test environment
- `LOCAL` otherwise
- `RuntimeMode.CLOUD` may remain for compatibility with existing tests/call sites but is not selected by normal local
runtime resolution.
## Verification Checklist
1. Loading config with legacy `cloud_mode` succeeds.
2. Saving config strips legacy `cloud_mode`.
3. `--local/--cloud` always override per-project mode for that command.
4. No-project + no-flags commands route local by default.
5. `bm cloud login/logout` do not toggle routing behavior.
6. `bm mcp` stdio routes per-project mode; HTTP/SSE remain local-forced.
7. `bm project list` communicates dual local/cloud presence without ambiguity.
8. `bm project ls` output identifies route target explicitly.
+98 -26
View File
@@ -427,6 +427,8 @@ await write_note(
)
```
> **Important**: `write_note` errors if the note already exists. Use `edit_note` for incremental changes, or pass `overwrite=True` to replace.
**Well-structured note**:
```python
@@ -760,6 +762,9 @@ notes = await read_note(
identifier="memory://specs/*",
project="main"
)
# Cross-project URL (auto-routes to the correct project)
note = await read_note(identifier="memory://research/specs/api-design")
```
```python
@@ -1060,16 +1065,19 @@ results = await search_notes(
project="main"
)
# Metadata-only search
results = await search_by_metadata(
filters={"type": "spec", "status": "in-progress"},
# Metadata-only search (no query needed)
results = await search_notes(
metadata_filters={"type": "spec", "status": "in-progress"},
project="main"
)
```
### Search Types
**Text search (default)**:
Available types: `"text"`, `"title"`, `"permalink"`, `"vector"`/`"semantic"`, `"hybrid"`.
Default is `"hybrid"` when semantic search is enabled, `"text"` otherwise.
**Text search**:
```python
# Full-text search across all content
@@ -1080,15 +1088,50 @@ results = await search_notes(
)
```
**Semantic search**:
**Title and permalink search**:
```python
# Search by title only
results = await search_notes(query="API Design", search_type="title", project="main")
# Search by permalink
results = await search_notes(query="specs/api-design", search_type="permalink", project="main")
```
**Semantic/vector search**:
```python
# Semantic/vector search (if enabled)
results = await search_notes(
query="user login security",
search_type="semantic",
search_type="semantic", # or "vector"
project="main"
)
# Override similarity threshold
results = await search_notes(
query="user login security",
search_type="semantic",
min_similarity=0.5,
project="main"
)
```
**Hybrid search** (combines text + semantic):
```python
results = await search_notes(
query="authentication best practices",
search_type="hybrid",
project="main"
)
```
**Tag shorthand in query**:
```python
# Use tag: prefix as shorthand
results = await search_notes(query="tag:security", project="main")
```
### Search Response
@@ -2161,6 +2204,31 @@ active_project = projects[0]["name"]
results = await search_notes(query="test", project=active_project)
```
### Note Already Exists
**Error**: `write_note` called for a note that already exists
**Solution**:
```python
# Preferred: use edit_note for incremental updates
await edit_note(
identifier="Existing Topic",
operation="append",
content="\n- [update] new information",
project="main"
)
# Alternative: replace the entire note
await write_note(
title="Existing Topic",
content="# Existing Topic\n...",
folder="notes",
overwrite=True,
project="main"
)
```
### Entity Not Found
**Error**: Note doesn't exist
@@ -2716,14 +2784,15 @@ await write_note(
### Content Management
**write_note(title, content, folder, tags, note_type, project)**
- Create or update markdown notes
**write_note(title, content, folder, tags, note_type, overwrite, project)**
- Create new markdown notes (errors if note already exists unless overwrite=True)
- Parameters:
- `title` (required): Note title
- `content` (required): Markdown content
- `folder` (required): Destination folder
- `tags` (optional): List of tags
- `note_type` (optional): Type of note (stored in frontmatter). Can be "note", "person", "meeting", "guide", etc.
- `overwrite` (optional): Set to True to replace an existing note (default: error if exists)
- `project` (required unless default_project_mode): Target project
- Returns: Created/updated entity with permalink
- Example:
@@ -2890,19 +2959,20 @@ contents = await list_directory(
### Search & Discovery
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, min_similarity, project)**
- Search across knowledge base
- Parameters:
- `query` (required): Search query
- `query` (optional): Search query (not required for filter-only searches)
- `page` (optional): Page number (default: 1)
- `page_size` (optional): Results per page (default: 10)
- `search_type` (optional): "text" or "semantic"
- `search_type` (optional): "text", "title", "permalink", "vector"/"semantic", "hybrid" (default: "hybrid" when semantic enabled, "text" otherwise)
- `types` (optional): Entity type filter
- `entity_types` (optional): Observation category filter
- `after_date` (optional): Date filter (ISO format)
- `metadata_filters` (optional): Structured frontmatter filters (dict)
- `tags` (optional): Frontmatter tags filter (list)
- `metadata_filters` (optional): Structured frontmatter filters (dict, supports `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$between` operators)
- `tags` (optional): Frontmatter tags filter (list); also available via `tag:` query shorthand
- `status` (optional): Frontmatter status filter (string)
- `min_similarity` (optional): Override similarity threshold for vector/hybrid search
- `project` (required unless default_project_mode): Target project
- Returns: Matching entities with scores
- Example:
@@ -2915,18 +2985,11 @@ results = await search_notes(
)
```
**search_by_metadata(filters, limit, offset, project)**
- Metadata-only search using structured frontmatter
- Parameters:
- `filters` (required): Dict of field -> value (supports $in, $gt/$gte/$lt/$lte, $between)
- `limit` (optional): Max results (default: 20)
- `offset` (optional): Pagination offset (default: 0)
- `project` (required unless default_project_mode): Target project
- Returns: Matching entities
- Example:
**Metadata-only search (via search_notes)**
- Use `search_notes` with `metadata_filters` and no `query` for metadata-only searches:
```python
results = await search_by_metadata(
filters={"type": "spec", "status": "in-progress"},
results = await search_notes(
metadata_filters={"type": "spec", "status": "in-progress"},
project="main"
)
```
@@ -2978,6 +3041,15 @@ await delete_project(project_name="old-project")
status = await sync_status(project="main")
```
**list_workspaces()**
- List available workspaces (cloud)
- Parameters: None
- Returns: List of workspaces with metadata
- Example:
```python
workspaces = await list_workspaces()
```
### Visualization
**canvas(nodes, edges, title, folder, project)**
@@ -3246,8 +3318,8 @@ await edit_note(
project="main"
)
# Avoid: Complete rewrite
# (unless necessary for major restructuring)
# When full rewrite is needed, use overwrite=True
await write_note(title="Note", content="...", folder="notes", overwrite=True)
```
### 14. Tagging Strategy
+190 -61
View File
@@ -5,7 +5,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
## Overview
The cloud CLI enables you to:
- **Toggle cloud mode** - All regular `bm` commands work with cloud when enabled
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
- **Project-scoped sync** - Each project independently manages its sync configuration
- **Explicit operations** - Sync only what you want, when you want
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
@@ -17,6 +17,8 @@ Before using Basic Memory Cloud, you need:
- **Active Subscription**: An active Basic Memory Cloud subscription is required to access cloud features
- **Subscribe**: Visit [https://basicmemory.com/subscribe](https://basicmemory.com/subscribe) to sign up
- **Optional**: Cloud is optional. Local-first open-source usage continues without cloud.
- **OSS Discount**: Use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
If you attempt to log in without an active subscription, you'll receive a "Subscription Required" error with a link to subscribe.
@@ -38,7 +40,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
1. **Cloud-only** - Project exists on cloud, no local copy
2. **Cloud + Local (synced)** - Project has a local working directory that syncs
3. **Local-only** - Project exists locally (when cloud mode is disabled)
3. **Local-only** - Project exists locally and is not routed to cloud
**Example:**
@@ -48,9 +50,9 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
# - work: wants local sync at ~/work-notes
# - temp: cloud-only, no local sync needed
bm project add research --local-path ~/Documents/research
bm project add work --local-path ~/work-notes
bm project add temp # No local sync
bm project add research --cloud --local-path ~/Documents/research
bm project add work --cloud --local-path ~/work-notes
bm project add temp --cloud # No local sync
# Now you can sync individually (after initial --resync):
bm project bisync --name research
@@ -66,9 +68,9 @@ bm project bisync --name work
## Quick Start
### 1. Enable Cloud Mode
### 1. Authenticate Cloud Access
Authenticate and enable cloud mode:
Authenticate with cloud:
```bash
bm cloud login
@@ -76,11 +78,12 @@ bm cloud login
**What this does:**
1. Opens browser to Basic Memory Cloud authentication page
2. Stores authentication token in `~/.basic-memory/auth/token`
3. **Enables cloud mode** - all CLI commands now work against cloud
4. Validates your subscription status
2. Stores authentication tokens in `~/.basic-memory/basic-memory-cloud.json`
3. Validates your subscription status
4. Leaves routing behavior unchanged (auth only)
**Result:** All `bm project`, `bm tools` commands now work with cloud.
**Result:** Cloud credentials are available for cloud-routed commands.
Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% off for 3 months.
### 2. Set Up Sync
@@ -104,10 +107,10 @@ Create projects with optional local sync paths:
```bash
# Create cloud project without local sync
bm project add research
bm project add research --cloud
# Create cloud project WITH local sync
bm project add research --local-path ~/Documents/research
bm project add research --cloud --local-path ~/Documents/research
# Or configure sync for existing project
bm project sync-setup research ~/Documents/research
@@ -117,7 +120,7 @@ bm project sync-setup research ~/Documents/research
When you add a project with `--local-path`:
1. Project created on cloud at `/app/data/research`
2. Local path stored in config: `cloud_projects.research.local_path = "~/Documents/research"`
2. Local path stored in config for that project (`local_sync_path`)
3. Local directory created if it doesn't exist
4. Bisync state directory created at `~/.basic-memory/bisync-state/research/`
@@ -179,7 +182,8 @@ bm cloud status
```
You should see:
- `Mode: Cloud (enabled)`
- `OAuth: token valid` (or missing/expired)
- `API Key: configured` (or not set)
- `Cloud instance is healthy`
- Instructions for project sync commands
@@ -187,16 +191,16 @@ You should see:
### Understanding Project Commands
**Key concept:** When cloud mode is enabled, use regular `bm project` commands (not `bm cloud project`).
**Key concept:** Use regular `bm project` commands (not `bm cloud project`).
```bash
# In cloud mode:
bm project list # Lists cloud projects
bm project add research # Creates cloud project
# Local route
bm project list --local
bm project add research ~/Documents/research
# In local mode:
bm project list # Lists local projects
bm project add research ~/Documents/research # Creates local project
# Cloud route
bm project list --cloud
bm project add research --cloud
```
### Creating Projects
@@ -204,7 +208,7 @@ bm project add research ~/Documents/research # Creates local project
**Use case 1: Cloud-only project (no local sync)**
```bash
bm project add temp-notes
bm project add temp-notes --cloud
```
**What this does:**
@@ -217,7 +221,7 @@ bm project add temp-notes
**Use case 2: Cloud project with local sync**
```bash
bm project add research --local-path ~/Documents/research
bm project add research --cloud --local-path ~/Documents/research
```
**What this does:**
@@ -251,11 +255,35 @@ bm project list
```
**What you see:**
- All projects in cloud (when cloud mode enabled)
- Local projects always
- Cloud projects when credentials are available
- Default project marked
- Project paths shown
- Route-related metadata (for example, local/cloud presence and sync info)
**Future:** Will show sync status (synced/not synced, last sync time).
Example shape (single row for dual-presence projects):
```text
Name Path Local Path Cloud Path CLI Default MCP (stdio)
main /basic-memory ~/basic-memory /basic-memory local local
specs /specs ~/dev/specs /specs cloud local
```
### When a Project Exists in Both Local and Cloud
Use routing flags to disambiguate command targets:
```bash
# Force local target for this command
bm project info main --local
bm project ls --name main --local
# Force cloud target for this command
bm project info main --cloud
bm project ls --name main --cloud
```
Default behavior for no-project, no-flag commands is local.
For MCP stdio, routing is always local.
## File Synchronization
@@ -363,24 +391,28 @@ bm project bisync --name research --dry-run
**Result:** Safe preview of sync operations.
### Advanced: List Remote Files
### Advanced: List Project Files by Route
**Use case:** See what files exist on cloud without syncing.
**Use case:** Inspect local or cloud project files explicitly.
```bash
# List all files in project
# List local project files (default target when no route flag is given)
bm project ls --name research
bm project ls --name research --local
# List cloud project files
bm project ls --name research --cloud
# List files in subdirectory
bm project ls --name research --path subfolder
bm project ls --name research --cloud --path subfolder
```
**What happens:**
1. Connects to cloud via rclone
2. Lists files in remote project path
1. Resolves route from flags (or local default when no route is given)
2. Lists files for the chosen project instance
3. No files transferred
**Result:** See cloud file listing.
**Result:** See file listing for the target route.
## Multiple Projects
@@ -390,9 +422,9 @@ bm project ls --name research --path subfolder
```bash
# Setup multiple projects
bm project add research --local-path ~/Documents/research
bm project add work --local-path ~/work-notes
bm project add personal --local-path ~/personal
bm project add research --cloud --local-path ~/Documents/research
bm project add work --cloud --local-path ~/work-notes
bm project add personal --cloud --local-path ~/personal
# Establish baselines
bm project bisync --name research --resync
@@ -417,12 +449,12 @@ bm project bisync --all # Coming soon
```bash
# Projects with sync
bm project add research --local-path ~/Documents/research
bm project add work --local-path ~/work
bm project add research --cloud --local-path ~/Documents/research
bm project add work --cloud --local-path ~/work
# Cloud-only projects
bm project add archive
bm project add temp-notes
bm project add archive --cloud
bm project add temp-notes --cloud
# Sync only the configured ones
bm project bisync --name research
@@ -433,20 +465,101 @@ bm project bisync --name work
**Result:** Fine-grained control over what syncs.
## Disable Cloud Mode
## Per-Project Cloud Routing (API Key)
Return to local mode:
Route individual projects through cloud using an API key. This lets you keep some projects local while others route through cloud.
### Setting Up API Key Auth
**Option A: Create a key in the web app, then save it locally:**
```bash
bm cloud set-key bmc_abc123...
```
**Option B: Create a key via CLI (requires OAuth login first):**
```bash
bm cloud login # One-time OAuth login
bm cloud create-key "my-laptop" # Creates key and saves it locally
```
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
### Setting Project Modes
```bash
# Route a project through cloud
bm project set-cloud research
# Revert to local mode
bm project set-local research
# View project modes
bm project list
```
**What happens:**
- `set-cloud`: validates the API key exists, then sets the project mode to `cloud` in config
- `set-local`: reverts the project to local mode (removes the mode entry from config)
- MCP tools and CLI commands for that project will route to `cloud_host/proxy` with the API key as Bearer token
### How It Works
When an MCP tool or CLI command runs for a cloud-mode project:
1. `get_client(project_name="research")` checks the project's mode in config
2. If mode is `cloud`, creates an HTTP client pointed at `cloud_host/proxy` with `Authorization: Bearer bmc_...`
3. If mode is `local` (default), uses the in-process ASGI transport as usual
**Routing priority** (highest to lowest):
1. Factory injection (cloud app, tests)
2. Explicit route override (`--local` / `--cloud`)
3. Per-project cloud mode (API key)
4. Local ASGI transport (default)
Route override environment variables:
- `BASIC_MEMORY_FORCE_LOCAL=true`
- `BASIC_MEMORY_FORCE_CLOUD=true`
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`
No-project, no-flag CLI commands default to local routing.
### Configuration Example
```json
{
"projects": {
"personal": "/Users/me/notes",
"research": "/Users/me/research"
},
"project_modes": {
"research": "cloud"
},
"cloud_api_key": "bmc_abc123...",
"cloud_host": "https://cloud.basicmemory.com",
"default_project": "personal"
}
```
In this example, `personal` stays local and `research` routes through cloud. Projects not listed in `project_modes` default to local.
### Sync Behavior
Cloud-mode projects are automatically skipped during local file sync (background sync and file watching). Their files live on the cloud instance, not locally.
## OAuth Logout
```bash
bm cloud logout
```
**What this does:**
1. Disables cloud mode in config
2. All commands now work locally
3. Auth token remains (can re-enable with login)
1. Removes stored OAuth token(s)
2. Does not change per-project route configuration
3. Does not change command routing defaults
**Result:** All `bm` commands work with local projects again.
**Result:** OAuth session is cleared. API-key-based routing still works if `cloud_api_key` is configured.
## Filter Configuration
@@ -653,12 +766,20 @@ If instance is down, wait a few minutes and retry.
## Command Reference
### Cloud Mode Management
### Cloud Authentication
```bash
bm cloud login # Authenticate and enable cloud mode
bm cloud logout # Disable cloud mode
bm cloud status # Check cloud mode and instance health
bm cloud login # Authenticate and store OAuth credentials
bm cloud logout # Remove stored OAuth credentials
bm cloud status # Check auth state and instance health
bm cloud promo --off # Disable CLI cloud promo notices
```
### API Key Management
```bash
bm cloud set-key <key> # Save a cloud API key (bmc_ prefixed)
bm cloud create-key <name> # Create API key via cloud API (requires OAuth login)
```
### Setup
@@ -669,16 +790,22 @@ bm cloud setup # Install rclone and configure credentials
### Project Management
When cloud mode is enabled:
```bash
bm project list # List cloud projects
bm project add <name> # Create cloud project (no sync)
bm project add <name> --local-path <path> # Create with local sync
bm project list --local # Local project list
bm project list --cloud # Cloud project list
bm project add <name> --cloud # Create cloud project (no sync)
bm project add <name> --cloud --local-path <path> # Create with local sync
bm project sync-setup <name> <path> # Add sync to existing project
bm project rm <name> # Delete project
```
### Per-Project Routing
```bash
bm project set-cloud <name> # Route project through cloud (requires API key)
bm project set-local <name> # Revert project to local mode
```
### File Synchronization
```bash
@@ -697,18 +824,20 @@ bm project bisync --name <project> --verbose
bm project check --name <project>
bm project check --name <project> --one-way
# List remote files
bm project ls --name <project>
bm project ls --name <project> --path <subpath>
# List project files by route
bm project ls --name <project> # Default target: local
bm project ls --name <project> --local
bm project ls --name <project> --cloud
bm project ls --name <project> --cloud --path <subpath>
```
## Summary
**Basic Memory Cloud uses project-scoped sync:**
1. **Enable cloud mode** - `bm cloud login`
1. **Authenticate cloud access** - `bm cloud login`
2. **Install rclone** - `bm cloud setup`
3. **Add projects with sync** - `bm project add research --local-path ~/Documents/research`
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
5. **Establish baseline** - `bm project bisync --name research --resync`
6. **Daily workflow** - `bm project bisync --name research`
+91
View File
@@ -0,0 +1,91 @@
# Cloud Semantic Search Value (Customer-Facing Technical Story)
This document explains why teams should buy cloud semantic search even when local search exists.
## Core Promise
Markdown files remain the source of truth in both local and cloud modes.
- Files are portable.
- Search indexes are derived and rebuildable.
- You never get locked into proprietary document storage.
## The Customer Problem
Teams paying for cloud are usually not optimizing for "can this run locally." They are optimizing for:
- finding the right note the first time,
- keeping retrieval quality high as note volume grows,
- avoiding search slowdowns while content is actively changing,
- getting consistent results across users, agents, and sessions.
## Why Cloud Is the Aspirin
Cloud semantic search is the immediate pain reliever because it fixes the problems users feel right now.
### 1) Better hit rate on real queries
Cloud uses stronger managed embeddings than the default local model, which improves semantic recall for paraphrases and vague questions.
Customer outcome:
- fewer "I know this exists but search missed it" moments,
- less query rewording,
- faster time to answer.
### 2) Better behavior under active workloads
Cloud indexing runs out of band in workers, so indexing does not compete with interactive read/write traffic.
Customer outcome:
- stable search responsiveness during heavy updates,
- fresher semantic results shortly after edits,
- less user-visible performance variance.
### 3) Better consistency for shared knowledge
Cloud retrieval runs against a centralized tenant index, so teams and agents resolve against the same semantic state.
Customer outcome:
- fewer "works on my machine" search differences,
- more predictable agent behavior across environments,
- easier cross-user collaboration on large knowledge bases.
### 4) Better quality at higher scale
With Postgres + `pgvector` per tenant, cloud can sustain larger note collections and higher query volumes than typical local setups.
Customer outcome:
- confidence as repositories grow to tens of thousands of notes,
- less need for user-side tuning,
- fewer quality regressions as usage increases.
## Local Is the Vitamin
Local semantic search still matters and should stay strong.
- offline use,
- privacy-first operation,
- no cloud dependency,
- user-controlled runtime.
It compounds long-term ownership and resilience, but does not remove the immediate pain points cloud solves for teams at scale.
## Recommended Messaging
One-liner:
"Cloud semantic search is the aspirin: it fixes retrieval quality and performance pain now. Local semantic search is the vitamin: it builds long-term control and resilience."
Long form:
"Basic Memory keeps markdown as the source of truth everywhere. Local gives privacy and offline control. Cloud adds immediate, measurable improvements in search quality, consistency, and responsiveness for teams and agents running at scale."
## Packaging Guidance
- Base: local FTS plus optional local semantic search.
- Cloud value: higher semantic quality, stable performance under load, and consistent team-wide retrieval.
- Keep interfaces pluggable (`EmbeddingProvider`, vector backend protocol) so implementation can evolve without changing user workflows.
+138
View File
@@ -0,0 +1,138 @@
# MCP UI Bakeoff - Instructions & Test Plan
Last updated: 2026-02-02
## Scope
Compare three presentation paths for Basic Memory MCP tools:
1. **ToolUI (React)** via MCP App resources.
2. **MCPUI Python SDK** embedded UI resources (legacy host path).
3. **ASCII/ANSI** output for TUI clients.
This doc is the running instruction set and test plan. Update as implementation progresses.
---
## Prerequisites
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
- Node for toolui build (already used for POC)
- Python 3.12+ with `uv`
Optional (for MCPUI Python SDK path):
- Local repo: `/Users/phernandez/dev/mcp-ui`
- Install the server SDK into the Basic Memory venv:
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
---
## Build / Refresh Steps
### ToolUI React bundle
```bash
cd ui/tool-ui-react
npm install
npm run build
```
This regenerates:
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
---
## How to Run the MCP Server
```bash
basic-memory mcp --transport stdio
```
Optional to pick UI variant for MCP App resources:
```bash
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
```
---
## Test Cases
### 1) MCP App Resource UI (toolui / vanilla / mcpui)
Tools:
- `search_notes`
- `read_note`
Expect:
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
- Variantspecific URIs also available:
- `ui://basic-memory/search-results/vanilla`
- `ui://basic-memory/search-results/tool-ui`
- `ui://basic-memory/search-results/mcp-ui`
- `ui://basic-memory/note-preview/vanilla`
- `ui://basic-memory/note-preview/tool-ui`
- `ui://basic-memory/note-preview/mcp-ui`
Manual check:
- Trigger tool in MCPAppcapable host and confirm UI renders.
---
### 2) Text / JSON Output Modes
Tools:
- `search_notes(output_format="text" | "json")`
- `read_note(output_format="text" | "json")`
- `write_note(output_format="text" | "json")`
- `edit_note(output_format="text" | "json")`
- `recent_activity(output_format="text" | "json")`
- `list_memory_projects(output_format="text" | "json")`
- `create_memory_project(output_format="text" | "json")`
- `delete_note(output_format="text" | "json")`
- `move_note(output_format="text" | "json")`
- `build_context(output_format="json" | "text")`
Expect:
- `text` mode preserves existing human-readable responses.
- `json` mode returns structured dict/list payloads for machine-readable clients.
Automated:
- `uv run pytest test-int/mcp/test_output_format_json_integration.py`
---
### 3) MCPUI Python SDK (embedded UI resource)
Tools (embedded resource responses):
- `search_notes_ui` (MCPUI SDK)
- `read_note_ui` (MCPUI SDK)
Expected output:
- Tool response content contains an EmbeddedResource (`type: "resource"`)
- `mimeType` is `text/html`
- `_meta` includes:
- `mcpui.dev/ui-preferred-frame-size`
- `mcpui.dev/ui-initial-render-data`
Manual check:
- Render tool responses using `UIResourceRenderer` (legacy host flow).
Automated (if SDK installed):
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
---
## Bakeoff Notes Template
Fill in after running:
- ToolUI (React): __
- MCPUI SDK (embedded): __
- Text/JSON modes: __
Decision + rationale: __
+260
View File
@@ -0,0 +1,260 @@
# Metadata Search Reference
Basic Memory automatically indexes custom frontmatter fields so you can query them with structured filters. Any YAML key in a note's frontmatter beyond the standard set (`title`, `type`, `tags`, `permalink`, `schema`) is stored as `entity_metadata` and becomes searchable.
## Querying with `search_notes`
`search_notes` is the single search tool for all queries — text, metadata filters, or both. The `query` parameter is optional, so you can use metadata filters alone without passing an empty string.
## Filter Syntax
Filters are a JSON dictionary where each key targets a frontmatter field and the value specifies the match condition. Multiple keys combine with **AND** logic — every filter must match.
### Equality
Match a single value exactly.
```json
{"status": "active"}
```
Finds notes whose frontmatter contains `status: active`.
### Array Contains (all)
Pass a list to require **all** listed values to be present in the field.
```json
{"tags": ["security", "oauth"]}
```
Finds notes tagged with both `security` and `oauth`.
### `$in` (any of)
Match if the field equals **any** value in the list.
```json
{"priority": {"$in": ["high", "critical"]}}
```
### `$gt`, `$gte`, `$lt`, `$lte`
Numeric and text comparisons. Numeric values use numeric comparison; strings use lexicographic comparison.
```json
{"confidence": {"$gt": 0.7}}
{"score": {"$lte": 100}}
```
### `$between`
Range filter (inclusive). Takes a `[min, max]` pair.
```json
{"score": {"$between": [0.3, 0.8]}}
```
### Nested Access (dot notation)
Access nested frontmatter values using dots.
```json
{"schema.version": "2"}
```
This queries the `version` key inside a `schema` object in frontmatter.
### Summary Table
| Operator | Syntax | Example |
|----------|--------|---------|
| Equality | `{"field": "value"}` | `{"status": "active"}` |
| Array contains (all) | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
| `$in` (any of) | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
| `$lt` / `$lte` | `{"field": {"$lt": N}}` | `{"score": {"$lt": 0.5}}` |
| `$between` | `{"field": {"$between": [min, max]}}` | `{"score": {"$between": [0.3, 0.8]}}` |
| Nested access | `{"a.b": "value"}` | `{"schema.version": "2"}` |
**Key rules:**
- Filter keys must match `[A-Za-z0-9_-]+` (dots separate nesting levels).
- Each operator dict must contain exactly one operator.
- `$in` and array-contains require non-empty lists.
- `$between` requires exactly two values `[min, max]`.
## MCP Tool — `search_notes`
`search_notes` is the single search tool for text queries, metadata filters, or both. The `query` parameter is optional.
**Relevant parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `query` | string (optional) | Text search query. Omit for filter-only searches. |
| `metadata_filters` | dict | Structured filter dict (see syntax above) |
| `tags` | list[str] | Convenience shorthand — merged into `metadata_filters["tags"]` |
| `status` | string | Convenience shorthand — merged into `metadata_filters["status"]` |
**Merging rules:** `tags` and `status` are convenience shortcuts. They are merged into `metadata_filters` using `setdefault` — if the same key already exists in `metadata_filters`, the explicit filter wins.
**Examples:**
```python
# Text search filtered by metadata
await search_notes("authentication", metadata_filters={"status": "draft"})
# Filter-only search (no query needed)
await search_notes(metadata_filters={"type": "spec"})
# Combine text, tags shortcut, and metadata
await search_notes(
"oauth flow",
tags=["security"],
metadata_filters={"confidence": {"$gt": 0.7}},
)
# Convenience shortcuts
await search_notes("planning", status="active")
await search_notes(tags=["tier1", "alpha"])
```
## Tag Search Shortcuts
The `tag:` prefix in a search query is a shorthand for tag-based metadata filtering. When `search_notes` receives a query starting with `tag:`, it converts the query into a `tags` filter and clears the text query.
```python
# These are equivalent:
await search_notes("tag:tier1")
await search_notes("", tags=["tier1"])
# Multiple tags (comma or space separated) — all must be present:
await search_notes("tag:tier1,alpha")
await search_notes("tag:tier1 alpha")
```
## CLI Access
The `bm tool search-notes` command exposes metadata filtering via `--meta` and `--filter` flags.
### `--meta` — simple key=value filters
Repeatable flag for equality filters on frontmatter fields.
```bash
# Single filter
bm tool search-notes "my query" --meta status=draft
# Multiple filters (AND logic)
bm tool search-notes "" --meta status=active --meta priority=high
```
### `--filter` — advanced JSON filters
Pass a full JSON filter dictionary for operator-based queries.
```bash
# Range filter
bm tool search-notes "" --filter '{"score": {"$between": [0.3, 0.8]}}'
# $in filter
bm tool search-notes "" --filter '{"priority": {"$in": ["high", "critical"]}}'
```
### `--tag` and `--status` — convenience shortcuts
```bash
bm tool search-notes "query" --tag security --tag oauth
bm tool search-notes "" --status draft
```
### Combined example
```bash
bm tool search-notes "authentication" --tag security --meta status=draft --type spec
```
## Practical Examples
### Example notes with custom frontmatter
**`specs/auth-design.md`:**
```markdown
---
title: Auth Design
type: spec
tags: [security, oauth]
status: in-progress
priority: high
confidence: 0.85
---
# Auth Design
## Observations
- [decision] Use OAuth 2.1 with PKCE for all client types #security
- [requirement] Token refresh must be transparent to the user
## Relations
- implements [[Security Requirements]]
```
**`specs/search-redesign.md`:**
```markdown
---
title: Search Redesign
type: spec
tags: [search, performance]
status: draft
priority: medium
confidence: 0.6
---
# Search Redesign
## Observations
- [goal] Sub-100ms search response times #performance
- [approach] Hybrid FTS + vector retrieval
## Relations
- depends_on [[Database Schema]]
```
### Queries that find them
```python
# Find all in-progress specs
await search_notes(metadata_filters={"status": "in-progress", "type": "spec"})
# → Auth Design
# Find high-confidence specs
await search_notes(metadata_filters={"confidence": {"$gt": 0.7}})
# → Auth Design (confidence: 0.85)
# Find specs with priority high or medium
await search_notes(metadata_filters={"priority": {"$in": ["high", "medium"]}})
# → Auth Design, Search Redesign
# Find specs in a confidence range
await search_notes(metadata_filters={"confidence": {"$between": [0.5, 0.9]}})
# → Auth Design (0.85), Search Redesign (0.6)
# Find notes tagged with security
await search_notes("tag:security")
# → Auth Design
# Combined: text search + metadata filter
await search_notes("OAuth", metadata_filters={"status": "in-progress"})
# → Auth Design
```
### CLI equivalents
```bash
bm tool search-notes "" --meta status=in-progress --type spec
bm tool search-notes "" --filter '{"confidence": {"$gt": 0.7}}'
bm tool search-notes "OAuth" --meta status=in-progress
bm tool search-notes --tag security
```
+344
View File
@@ -0,0 +1,344 @@
# Post-v0.18.0 Test Plan and Acceptance Criteria
## Goal
Define a complete validation plan for all major features merged after `v0.18.0`, combining:
- Coverage-gap-driven automated tests
- Real MCP server integration tests (no mocks for target flows)
- Manual MCP verification via LLM-driven tool calls
This plan is based on commits in `v0.18.0..HEAD` and the latest `just check` coverage output.
## Scope Window
- Start tag: `v0.18.0` (2026-01-28)
- End: current `main`
- Change volume: 12 feature commits + 14 bug-fix commits (+ release chores/hotfixes)
## Execution Strategy
1. Stabilize all feature-level acceptance criteria in automated tests first.
2. Add black-box MCP integration tests for semantic search + schema (real server startup).
3. Run manual MCP tool-call verification to confirm real UX and routing behavior.
4. Re-run full gate: `just check` + targeted integration packs.
## Global Quality Gates
- Feature criteria below must all pass.
- No regressions in existing suites.
- Coverage improves in targeted low-coverage feature modules.
- SQLite and Postgres parity for search/semantic features.
## Priority Coverage Gaps (from latest run)
These are the most important post-`v0.18.0` feature modules currently under-covered:
- `src/basic_memory/mcp/tools/schema.py` (27%)
- `src/basic_memory/mcp/clients/schema.py` (36%)
- `src/basic_memory/mcp/tools/ui_sdk.py` (43%)
- `src/basic_memory/mcp/tools/search.py` (73%)
- `src/basic_memory/repository/postgres_search_repository.py` (63%)
- `src/basic_memory/mcp/async_client.py` (82%)
- `src/basic_memory/api/v2/routers/schema_router.py` (80%)
## Feature Acceptance Criteria and Test Plan
### 1) Schema System (`c97733d`) — DONE
### Acceptance criteria
- `schema_validate`, `schema_infer`, and `schema_diff` produce consistent outcomes across CLI/API/MCP for the same fixture set.
- Strict validation fails deterministically on required-field/type violations.
- Validation warnings are stable and machine-readable in non-strict mode.
- Inference output is deterministic for unchanged input corpus.
- Drift diff output is deterministic and identifies missing/extra/type-mismatch fields correctly.
### Existing coverage anchor points
- `tests/schema/*`
- `tests/api/v2/test_schema_router.py`
- `test-int/test_schema/*`
### Gaps to close — DONE
- ~~MCP schema tool branches (`src/basic_memory/mcp/tools/schema.py`)~~ — 18 tests in `tests/mcp/test_tool_schema.py`
- ~~MCP schema client behavior (`src/basic_memory/mcp/clients/schema.py`)~~ — `tests/mcp/test_client_schema.py`
- ~~Schema router error-path branches (`src/basic_memory/api/v2/routers/schema_router.py`)~~ — `tests/api/v2/test_schema_router.py`
### Planned additions — DONE
- ~~Add MCP tool tests for `schema_validate` strict + non-strict result shapes.~~ **DONE**
- ~~Add MCP tool tests for `schema_infer` with explicit `entity_type` and inferred type fallback.~~ **DONE**
- ~~Add MCP tool tests for `schema_diff` empty-diff and non-empty-diff paths.~~ **DONE**
- ~~Add API tests for schema router invalid payload/edge error handling.~~ **DONE**
- Add integration test that starts MCP server and calls schema tools end-to-end on fixture notes. — deferred to backlog item 4.
### 2) Semantic Search (`0777879`, `1428d18`, `344e651`) — DONE
### Acceptance criteria
- `search_type=text|vector|hybrid` returns expected ranked results on canonical semantic corpus.
- Missing semantic dependencies fail fast with actionable install guidance.
- Reindex and provider/model changes produce valid vectors without dimension mismatch.
- SQLite and Postgres produce equivalent behavior for semantic modes on the same dataset.
- Generated-column migration path is valid on SQLite environments in use.
### Existing coverage anchor points
- `tests/repository/test_sqlite_vector_search_repository.py`
- `tests/repository/test_postgres_search_repository.py`
- `tests/services/test_semantic_search.py`
- `tests/mcp/test_tool_search.py`
- `test-int/test_search_performance_benchmark.py`
### Gaps to close — DONE
- ~~Uncovered Postgres vector/hybrid branches~~ — 20 tests in `tests/repository/test_postgres_search_repository_unit.py` + 5 integration tests in `test-int/semantic/test_semantic_coverage.py`
- ~~MCP search semantic/output branches~~ — expanded `tests/mcp/test_tool_search.py`
### Planned additions — DONE
- ~~Expand Postgres repository tests for vector query composition edge cases.~~ **DONE**
- ~~Expand Postgres repository tests for hybrid fusion ranking and pagination branches.~~ **DONE**
- ~~Expand Postgres repository tests for embedding/provider error handling branches.~~ **DONE**
- ~~Expand MCP search tool tests for vector/hybrid output formatting branches.~~ **DONE**
- ~~Expand MCP search tool tests for semantic-disabled and missing-dependency failures.~~ **DONE**
- Add MCP integration tests that start server and execute semantic `search_notes` tool calls. — deferred to backlog item 4.
### Semantic search quality benchmarks (NEW)
Full benchmark suite in `test-int/semantic/` covering 5 backend×provider combinations:
- `sqlite-fts`, `sqlite-fastembed`, `postgres-fts`, `postgres-fastembed`, `postgres-openai`
- Quality metrics: hit@1, recall@5, MRR@10 with per-query timing
- Realistic corpus with cross-topic vocabulary overlap (240 notes, 4 topics)
- Rich CLI viewer: `just semantic-report`
- JSON artifact output: `just test-semantic-report`
Key finding: **FastEmbed (384-d local ONNX) matches or exceeds OpenAI (1536-d) quality at 30x lower latency.** Recommending FastEmbed as default for both local and cloud deployments.
### 3) Per-Project Local/Cloud Routing + API Key Auth (`d84708c`, `ed94877`, `312662f`) — DONE
### Acceptance criteria
- Project mode (`local`/`cloud`) persists and displays correctly.
- Routing selects ASGI for local projects and HTTP+Bearer for cloud projects.
- Cloud project without key fails with explicit remediation (`cloud set-key`/`cloud create-key`).
- Resolution precedence is correct (factory > force-local > per-project cloud > global fallback > local).
- Watch/sync only run for local projects.
### Existing coverage anchor points
- `tests/mcp/test_async_client_modes.py`
- `tests/cli/test_project_set_cloud_local.py`
- `tests/mcp/test_project_context.py`
- `tests/test_project_resolver.py`
- `tests/sync/test_watch_service_reload.py`
### Gaps to close — DONE
- ~~Cloud routing branch gaps in `src/basic_memory/mcp/async_client.py`~~ — expanded `tests/mcp/test_async_client_modes.py`
### Planned additions — DONE
- ~~Add branch-focused tests for all unresolved routing branches in `get_client()`.~~ **DONE**
- Add MCP integration scenario with mixed local/cloud project config — deferred to backlog item 4.
### 4) Project-Prefixed Permalinks + Memory URL Routing (`545804f`) — DONE
### Acceptance criteria
- Project-prefixed permalinks are generated consistently on create/update/import flows.
- Memory URLs resolve to the correct project/entity even with duplicate note titles.
- `read_note`, `search`, `build_context`, write/edit/move flows preserve project identity correctly.
- Link resolution remains correct for context-aware wikilinks.
### Existing coverage anchor points
- `tests/utils/test_permalink_formatting.py`
- `tests/mcp/test_tool_read_note.py`
- `tests/mcp/test_tool_search.py`
- `tests/services/test_context_service.py`
- `test-int/mcp/test_read_note_integration.py`
### Gaps to close
- No major coverage alarm in report, but keep as regression-critical due broad impact surface.
### Planned additions — DONE
- ~~Add one integration test with colliding titles across two projects and assert URL routing invariants.~~ **DONE**`test-int/mcp/test_permalink_collision_integration.py` (2 tests: collision across projects + memory:// URL routing with project prefix)
### 5) MCP UI Variants + TUI Output (`8bc03d1`) — DONE
### Acceptance criteria
- UI resource variant selection (`tool-ui`, `vanilla`, `mcp-ui`) follows env configuration.
- `search_notes` and `read_note` expose expected resource metadata for UI hosts.
- `ascii`/`ansi` outputs are deterministic and stable for terminal clients.
### Existing coverage anchor points
- `tests/mcp/test_tool_contracts.py`
- `test-int/mcp/test_output_format_json_integration.py`
- `test-int/mcp/test_ui_sdk_integration.py`
### Gaps to close — DONE
- ~~`src/basic_memory/mcp/tools/ui_sdk.py` branch coverage~~ — `tests/mcp/test_ui_sdk.py`
- ~~`src/basic_memory/mcp/ui/sdk.py` and `src/basic_memory/mcp/ui/templates.py` branch coverage~~ — `tests/mcp/test_ui_templates.py` + `tests/mcp/test_ui_resources.py`
### Planned additions — DONE
- ~~Add unit tests for UI SDK metadata generation and template selection branches.~~ **DONE** — 31 tests
- ~~Add integration assertion for variant-specific resource URIs and metadata payload shape.~~ **DONE**
### 6) Watch Command (`8df88e4`) — DONE
### Acceptance criteria
- `basic-memory watch` starts and processes create/update/delete events.
- Watch restart/reload path does not duplicate watchers.
- Cloud-mode projects are excluded from active watcher set.
### Existing coverage anchor points
- `tests/cli/test_watch.py`
- `tests/sync/test_coordinator.py`
- `tests/sync/test_watch_service_reload.py`
### Planned additions — DONE
- ~~Add one stress-style integration test for rapid file changes and watcher stability.~~ **DONE**`tests/sync/test_watch_service_stress.py` (3 tests: 50-file batch, mixed add/modify/delete batch, rapid modifications to same file)
### 7) CLI JSON Output (`a47c9c0`) — DONE
### Acceptance criteria
- `--format json` returns valid JSON with stable keys for success paths.
- Error paths also return JSON-shaped output with correct non-zero exits.
- Default human output remains unchanged.
### Existing coverage anchor points
- `tests/cli/test_cli_tool_json_output.py`
- `test-int/cli/test_cli_tool_json_integration.py`
### Planned additions — DONE
- ~~Add one failure-path integration test per high-use tool command.~~ **DONE**`test-int/cli/test_cli_tool_json_failure_integration.py` (4 tests: read-note not found, write-note missing content, write→read roundtrip, recent-activity empty project)
### 8) Search/Edit and Metadata Fixes (`530cbac`, `f1d50c2`, `8838571`, `009e849`) — DONE
### Acceptance criteria
- Metadata filters produce consistent results on SQLite and Postgres.
- `tag:` shorthand works alone and with mixed query terms.
- Fast write/edit paths preserve `external_id` and metadata integrity.
### Existing coverage anchor points
- `tests/repository/test_metadata_filters.py`
- `tests/repository/test_search_repository.py`
- `tests/services/test_search_service.py`
### Planned additions — DONE
- ~~Add Postgres-specific metadata filter edge-case tests to mirror SQLite assertions exactly.~~ **DONE**`tests/repository/test_metadata_filters_edge_cases.py` (6 tests: missing field, AND logic, contains single-element array, nested path missing intermediate, $gte/$lte boundaries, $between inclusive — all pass on both SQLite and Postgres)
### 9) Compatibility and Hotfix Regression Pack (`c46d7a6`, `a0e754b`, `343a6e1`, `24ca5f6`, `e3ced49`, `8489a3d`, `b609c4e`, `f6e0a5b`, `7624a20`)
### Acceptance criteria
- Legacy endpoints required by older CLI versions function without `405` (`GET /projects/projects`, `POST /projects/projects`, `POST /projects/config/sync`).
- Entity creation conflicts map to conflict status (not 500).
- `recent_activity` prompt defaults are correct.
- No spurious `metadata: {}` in serialized frontmatter.
- Tigris/rclone uses global consistency headers for all transaction types.
- `bm --version` fast path avoids heavy import path and remains responsive.
- Default SQLite DB path is isolated by config dir.
### Gaps to close
- ~~Commits with no direct tests added (`c46d7a6`, `344e651`, `f6e0a5b`) need explicit regression tests.~~ **DONE**
### Planned additions — DONE
- ~~Add API compat test covering all legacy endpoint methods and payloads.~~ **DONE**`test_legacy_v1_add_project_endpoint`, `test_legacy_v1_sync_config_endpoint`
- ~~Add CLI fast-path test for `--version` import behavior/performance guard.~~ **DONE**`test_bm_version_does_not_import_heavy_modules`
- ~~Add empty metadata serialization regression test.~~ **DONE**`test_schema_to_markdown_empty_metadata_no_metadata_key`
- Add migration safety test for SQLite generated columns (`VIRTUAL` expectation) — deferred, low risk.
## MCP Manual Verification Plan (LLM Tool Calls)
Run after automated tests pass.
### Setup
- Start MCP server: `basic-memory mcp --transport stdio`
- Use an MCP-capable client and issue tool calls directly.
### Manual scenarios
- Schema: call `schema_validate`, `schema_infer`, and `schema_diff` on known fixtures.
- Schema: verify error and success payloads match acceptance criteria.
- Semantic search: call `search_notes` with `search_type=text|vector|hybrid`.
- Semantic search: verify ranking relevance on semantic fixture queries.
- Routing: call tools with explicit project on mixed local/cloud setup.
- Routing: verify success/failure paths with and without API key.
- Permalink routing: read/write/search notes across projects with colliding titles.
- Permalink routing: verify memory URL routing correctness.
- UI/TUI: call `search_notes` and `read_note` with UI variants and `output_format=text|json`.
- UI/TUI: verify payload/resource format and metadata completeness.
## Implementation Backlog (Ordered)
1. ~~Fill schema MCP/client/router coverage gaps.~~ **DONE** — 18 tests in `test_tool_schema.py` + `test_client_schema.py`
2. ~~Fill semantic search MCP + Postgres repository gaps.~~ **DONE** — 20 tests in `test_postgres_search_repository_unit.py` + `test_tool_search.py`
3. ~~Add compatibility regression tests (legacy endpoints, migration, version fast path).~~ **DONE** — 5 tests across 3 files (see below)
4. ~~Add feature-level integration tests (permalinks, watch, CLI JSON, metadata filters).~~ **DONE** — 15 tests across 4 files (see items 4, 6, 7, 8 above)
5. ~~Expand UI SDK and template branch tests.~~ **DONE** — 31 tests in `test_ui_templates.py` + `test_ui_sdk.py` + `test_ui_resources.py`
6. ~~Run full gate and capture results in a short release readiness summary.~~ **DONE** — see results below
### Full Gate Results (`just check`)
| Phase | Result |
|-------|--------|
| lint | PASS |
| format | PASS |
| typecheck | PASS |
| Unit tests (SQLite) | 1788 passed, 15 skipped |
| Integration tests (SQLite) | 243 passed, 4 skipped, 10 deselected |
| Unit tests (Postgres) | 1760 passed, 28 skipped |
| Integration tests (Postgres) | 234 passed, 13 skipped, 10 deselected |
**0 failures. 10 deselected = semantic benchmark tests (run separately via `just test-semantic`).**
### Item 3 Details — Compatibility Regression Tests
| Test | File | What it covers |
|------|------|----------------|
| `test_legacy_v1_add_project_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/projects` legacy route reachable (idempotent path) |
| `test_legacy_v1_sync_config_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/config/sync` legacy route reachable |
| `test_bm_version_does_not_import_heavy_modules` | `tests/cli/test_cli_exit.py` | `bm --version` fast path does not load `basic_memory.mcp` |
| `test_schema_to_markdown_empty_metadata_no_metadata_key` | `tests/markdown/test_entity_parser_error_handling.py` | `schema_to_markdown()` with `entity_metadata={}` emits no `metadata:` key |
| `test_legacy_v1_list_projects_endpoint` | `tests/api/v2/test_project_router.py` | (pre-existing) GET `/projects/projects` legacy route |
**Suite totals after item 3: 1764 passed, 15 skipped, 0 failures.**
## Suggested Commands
- Full suite: `just check`
- Fast loop: `just fast-check`
- E2E consistency: `just doctor`
- SQLite focused: `just test-sqlite`
- Postgres focused: `just test-postgres`
- Schema integration: `pytest test-int/test_schema -q`
- Semantic + repo focus: `pytest tests/repository/test_postgres_search_repository.py tests/mcp/test_tool_search.py tests/services/test_semantic_search.py -q`
- MCP integration focus: `pytest test-int/mcp -q`
## Exit Criteria for This Plan
- All feature acceptance criteria above are validated.
- All identified high-priority coverage gaps are addressed or explicitly documented as intentional.
- Manual MCP verification scenarios complete with no P0/P1 findings.
+318
View File
@@ -0,0 +1,318 @@
# v0.19.0 Release Notes
## Overview
v0.19.0 is a major release that introduces semantic vector search, a schema validation system,
project-prefixed permalinks, per-project cloud routing, and a significant upgrade to FastMCP 3.0.
It includes 90+ commits since v0.18.0 spanning new features, architectural improvements, and
stability fixes across both SQLite and Postgres backends.
---
## Major Features
### Semantic Vector Search
Full vector and hybrid search for SQLite (via sqlite-vec) and Postgres (via pgvector).
- **Hybrid search mode** combines full-text search (FTS) with vector similarity for best results
- **Score-based fusion** replaces RRF for hybrid ranking — `max(vec, fts) + 0.3 * min(vec, fts)` preserves dominant signals and rewards dual-source agreement (#577)
- **Default search mode** is now `hybrid` when semantic search is enabled, `text` when disabled
- Embedding providers: FastEmbed (local, default) or OpenAI API
- Configurable similarity threshold via `semantic_min_similarity` (default 0.55)
- Per-query `min_similarity` override on `search_notes` tool
- Auto-backfill: existing entities get embeddings generated on first startup
- Backend-specific distance-to-similarity conversion (cosine for SQLite, inner product for Postgres)
- FTS fallback: if semantic dependencies are missing, search gracefully degrades to text-only
- sqlite-vec knn `k` parameter capped at 4096 to prevent backend errors
**Configuration:**
```json
{
"semantic_search_enabled": true,
"semantic_embedding_provider": "fastembed",
"semantic_embedding_model": "bge-small-en-v1.5",
"semantic_min_similarity": 0.55
}
```
**Usage:**
```
search_notes("machine learning concepts", search_type="hybrid")
search_notes("similar to my notes on coffee", search_type="vector")
search_notes("exact phrase match", search_type="text")
search_notes("broad search", min_similarity=0.3) # lower threshold for more results
```
### Schema System
Validate note structure against user-defined schemas with frontmatter-based rules.
- Define schemas as YAML in note frontmatter with field types, required fields, and constraints
- Frontmatter validation during sync — malformed notes get clear error messages
- Schema inference from existing notes to bootstrap schemas from your content
- Schema diff to compare two schemas and see changes
- Available via MCP tools and CLI
### Project-Prefixed Permalinks
Permalinks now include the project name for unambiguous cross-project references.
- Memory URLs like `memory://project-name/folder/note` route to the correct project
- Existing non-prefixed permalinks continue to work (backwards compatible)
- Controlled by `permalinks_include_project` config (default: true)
- `build_context` and `search_notes` auto-detect project from URL prefix
### Per-Project Cloud Routing
Individual projects can be routed through the cloud while others stay local.
- Set a project to cloud mode: `bm project set-cloud research`
- Revert to local: `bm project set-local research`
- Uses API key authentication: `bm cloud set-key bmc_abc123...`
- MCP tools automatically route based on each project's mode
- Local MCP server (`bm mcp`) still uses local routing for all projects by default
- `--local` and `--cloud` CLI flags override per-command
### Workspace Selection
Cloud projects can target specific workspaces for multi-tenant environments.
- `workspace` parameter on MCP tools for explicit workspace targeting
- CLI workspace-aware project listing with `bm project list`
- Spinner feedback while fetching cloud projects
---
## New Tools and Capabilities
### Dashboard (`bm project info`)
`bm project info` now displays an htop-inspired compact dashboard with:
- Horizontal bar charts for note types (top 5)
- Embedding coverage bar with Unicode block characters
- Colored status dots for at-a-glance health
- `EmbeddingStatus` schema and `get_embedding_status()` service method for programmatic access
### Unified Metadata Search
`search_by_metadata` has been merged into `search_notes` — one tool for all searches.
`query` is now optional, so you can search purely by frontmatter metadata.
```
search_notes(metadata_filters={"status": "in-progress"})
search_notes(metadata_filters={"tags": ["security", "oauth"]})
search_notes(metadata_filters={"priority": {"$in": ["high", "critical"]}})
search_notes(metadata_filters={"schema.confidence": {"$gt": 0.7}})
search_notes(tags=["security"]) # convenience shorthand
search_notes(status="draft") # convenience shorthand
```
### JSON Output Mode
All MCP tools now support `output_format="json"` for machine-readable responses.
- Default remains `"text"` for human-readable output (no breaking changes)
- `build_context` defaults to `"json"` with slimmed payloads (redundant fields stripped)
- CLI tool commands support `--format json` flag
### `tag:` Search Shorthand
Search by tag using convenient shorthand syntax.
```
search_notes("tag:security")
search_notes("tag:coffee AND tag:brewing")
```
### Entity User Tracking
Entities now track `created_by` and `last_updated_by` fields for attribution.
### Improved Search Result Content (#609)
Search results now surface more relevant context:
- `matched_chunk_text` populated for FTS-only hybrid results (no more fallback to truncated content)
- `TOP_CHUNKS_PER_RESULT` increased from 3 to 5, catching answers deeper in large notes (~2700 → ~4500 chars)
- `CONTENT_DISPLAY_LIMIT` doubled from 2000 to 4000 chars for results without matched chunks
### `write_note` Overwrite Guard (#632)
`write_note` is now non-idempotent by default. If a note already exists, the tool returns an
error instead of silently overwriting. Pass `overwrite=True` to replace, or use `edit_note`
for incremental updates. Config option `write_note_overwrite_default` restores the old upsert
behavior.
---
## Architecture Changes
### Score-Based Hybrid Fusion (#577)
RRF (Reciprocal Rank Fusion) compressed all fused scores to ~0.016, destroying ranking
differentiation. The new formula `max(vec, fts) + FUSION_BONUS * min(vec, fts)` preserves
dominant signals and rewards dual-source agreement. Zero-score results now produce zero
fused score instead of receiving a 0.1 weight floor.
### FastMCP 3.0 Upgrade
Upgraded from FastMCP 2.12.3 to 3.0.1.
- Tool annotations (`readOnlyHint`, `openWorldHint`) for better client integration
- Improved MCP protocol compliance
- Better error handling and context management
### Prompts Call MCP Tools Directly
MCP prompts (`search`, `continue_conversation`) now call MCP tools directly instead of
going through API endpoints. This fixes empty results in discovery mode and ensures prompts
use the same resolution logic as tools (including LinkResolver fallback).
### build_context LinkResolver Fallback
`build_context` now falls back to LinkResolver when an exact permalink lookup returns empty.
This uses the same 7-strategy resolution pipeline as `read_note`, so callers no longer get
empty results for valid note identifiers that don't match exact permalinks.
### Sync Handles Semantic Dependency Errors Gracefully
When sqlite-vec or another embedding provider is unavailable, `sync_file` now catches
`SemanticDependenciesMissingError` separately. The entity is created and FTS-indexed
successfully — only vector embeddings are skipped, with a clear warning:
```
WARNING: Semantic search dependencies missing — vector embeddings skipped for path=note.md.
Run 'bm reindex --embeddings' after resolving the dependency issue.
```
### Unified Project Path
Cloud projects with bisync now store the local filesystem path in `path` (not the Docker
container path). Config migration automatically promotes `local_sync_path``path` for
existing configs.
---
## CLI Improvements
### Status and Doctor Default to Local Routing
`bm status` and `bm doctor` now default to local routing since they scan the local filesystem.
Previously, cloud-mode projects would route these commands to the cloud API, which returned
Docker-internal paths that don't exist locally.
### `--format json` for CLI Tool Commands
All `bm tool` subcommands support `--format json` for machine-readable output, enabling
integration with scripts and plugins.
### `--json` for Top-Level CLI Commands
Five additional CLI commands now support `--json` for machine-readable output:
- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files
- `bm project list --json` — structured project list with name, paths, routing mode, and defaults
- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors
- `bm schema infer --json` — field frequency analysis and suggested schema definition
- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes
This complements the existing `bm project info --json` and `bm tool --format json` support,
making all major CLI commands scriptable for CI pipelines and automation.
### Cloud Promo and Analytics
- Cloud promo panel shown on first run or version bump with OSS discount code
- Anonymous usage telemetry via Umami Cloud (promo/login funnel events only)
- Opt out with `BASIC_MEMORY_NO_PROMOS=1`
- No PII, no file contents, no per-command tracking
- See [Telemetry](https://github.com/basicmachines-co/basic-memory#telemetry) in README
---
## Bug Fixes
- **#577**: RRF fusion compressed all hybrid scores to ~0.016, destroying ranking differentiation
- **#582**: build_context returns empty results on valid note identifiers
- **#575**: Remove hardcoded "main" default from default_project
- **#595**: recent_activity dedup and pagination across MCP tools
- **#593**: Backend-specific distance-to-similarity conversion
- **#592**: Strip NUL bytes from content before PostgreSQL search indexing
- **#562**: Use VIRTUAL instead of STORED columns in SQLite migration
- **#558**: Add X-Tigris-Consistent headers to all rclone commands
- **#541**: Handle EntityCreationError as conflict
- **#536**: Stabilize metadata filters on Postgres
- **#533**: Fix recent_activity prompt defaults
- **#530**: Prevent spurious `metadata: {}` in frontmatter output
- **#601**: Return matched chunk text in search results
- **#606**: Accept `null` for `expected_replacements` in `edit_note`
- **#579, #607**: Guard against closed streams in promo panel and missing vector tables on shutdown
- **#609**: FTS-only hybrid results missing `matched_chunk_text`; content limits too conservative
- **#631**: `build_context` related_results schema validation failure — replaced fragile `_slim_context()` stripping with Pydantic `exclude=True` field config
- **#630**: Skip workspace resolution when client factory is active — prevents 401 errors in cloud MCP server mode
- **#30**: `tag:` prefix query fails with hybrid search — moved tag prefix parsing to MCP tool level so it works with all search modes
- **#31**: `search_notes` returns cluttered observation/relation-level results — now defaults to entity-level results
- **#28**: `schema_infer` and `schema_diff` return raw Pydantic models as "undefined" in LLM output — added markdown formatters
- Fix `schema_validate` identifier resolution (now uses LinkResolver) and text rendering (markdown formatter)
- **#634**: `schema_validate` and `schema_diff` use stale database metadata instead of reading schema definitions from file — now reads frontmatter directly from the file with fallback to database metadata
- Fix `Post(**metadata)` crash when frontmatter contains `content` or `handler` keys
- Fix list-valued frontmatter fields (`title`, `type`) crashing on `.strip()` — now coerced to strings
- Cap sqlite-vec knn `k` parameter at 4096 to prevent backend errors
- Parameterize SQL queries in search repository type filters
- Double-default display in project list
- `ensure_frontmatter_on_sync` default changed to `True`
- Status/doctor commands fail with cloud-mode projects (Docker path error)
- Prompts return "0 projects" in discovery mode
---
## Security
- Upgrade `cryptography` for CVE advisory
- Upgrade `python-multipart` for security advisory
---
## Internal / Developer
- **#598**: Upgrade FastMCP 2.12.3 → 3.0.1 with tool annotations
- **#594**: Add `ty` as supplemental type checker
- **#538**: Add fast feedback loop tooling (`just fast-check`, `just doctor`, `just testmon`)
- **#600**: Rename `entity_type` to `note_type` for consistency
- **#596**: Fix CLI runtime defects and audit regressions
- CLI refactoring and workspace-aware cloud project listing
- Split and speed up PR test matrix in CI
- Fix CI: collect coverage from test jobs instead of re-running all tests
- Create `search_vector_chunks` in test fixtures for Postgres compatibility
---
## Configuration Changes
| Setting | Old Default | New Default | Notes |
|---------|-------------|-------------|-------|
| `semantic_search_enabled` | `false` | `true` | Semantic search on by default |
| `ensure_frontmatter_on_sync` | `false` | `true` | Frontmatter added during sync |
| `permalinks_include_project` | `false` | `true` | Project prefix in permalinks |
---
## Upgrade Notes
- **Semantic search dependencies** are now included by default. If sqlite-vec fails to load,
search gracefully falls back to FTS. Run `bm reindex --embeddings` to generate embeddings
for existing content.
- **Hybrid search scoring** has changed from RRF to score-based fusion. Search result ordering
may differ — results should be more accurate with better score differentiation.
- **`search_by_metadata`** is removed as a standalone tool. Use `search_notes` with
`metadata_filters` instead (same parameters, same behavior).
- **Project-prefixed permalinks** are enabled by default. Existing notes keep their current
permalinks until modified. Set `permalinks_include_project: false` to disable.
- **Frontmatter on sync** is now enabled by default. Files without frontmatter will have it
added on next sync. Set `ensure_frontmatter_on_sync: false` to preserve old behavior.
- **Config migration** runs automatically for cloud projects with bisync — `local_sync_path`
is promoted to `path` so filesystem operations work correctly.
- **`write_note` is no longer idempotent** — calls to `write_note` for existing notes now
return an error unless `overwrite=True` is passed. Use `edit_note` for incremental changes,
or set `write_note_overwrite_default: true` in config to restore the old behavior.
+209
View File
@@ -0,0 +1,209 @@
# Semantic Search Manual Test Log
## Overview
Manual test session for semantic (vector) search on the main project.
- Date: 2026-02-15
- Database: ~/.basic-memory/memory.db (SQLite)
- Entities: 456 embedded, 2714 vector chunks
- Search index: 2390 FTS entries
- Embedding model: default (384-dim, sqlite-vec)
## Test Plan
1. **Search Type Routing** — verify vector/hybrid/text dispatch, invalid search_type handling
2. **Conceptual Queries** — natural language where vector should beat FTS
3. **Keyword Queries** — exact terms where FTS should be strong
4. **Hybrid Ranking** — queries where both FTS and vector contribute
5. **Result Types** — entities, observations, relations in vector results
6. **Filters + Vector** — combine vector with types/entity_types/after_date
7. **Edge Cases** — short queries, long queries, empty, special chars, no-match
8. **Pagination** — page > 1, page_size respected
---
## Test Results
### Test 1: Search Type Routing
#### 1a: search_type="semantic" (invalid value)
- **Input:** query="how does the knowledge graph work", search_type="semantic"
- **Expected:** error or explicit fallback
- **Actual:** Silently falls through to text search (else branch in search.py:430)
- **Verdict:** BUG — should either be a recognized alias for "vector" or return an error
#### 1b: search_type="vector"
- **Input:** query="keeping AI context between sessions", search_type="vector"
- **Actual:** 5 results, scores ~0.58-0.59, found "Maintaining context across conversation boundaries" observation
- **Verdict:** PASS
#### 1c: search_type="text" with conceptual query
- **Input:** query="keeping AI context between sessions", search_type="text"
- **Actual:** 0 results (no exact keyword match)
- **Verdict:** PASS (expected — FTS requires token overlap)
#### 1d: search_type="hybrid" with conceptual query
- **Input:** query="keeping AI context between sessions", search_type="hybrid"
- **Actual:** 5 results, same ranking as vector (FTS contributed nothing here)
- **Verdict:** PASS
#### 1e: search_type="text" with keyword query
- **Input:** query="OAuth authentication", search_type="text"
- **Actual:** 3 results — AUTH.md Supabase OAuth, OAuth Rip-and-Replace, OAuth Integration Analysis
- **Verdict:** PASS
#### 1f: search_type="vector" with keyword query
- **Input:** query="OAuth authentication", search_type="vector"
- **Actual:** Same top results as text (keyword-rich content also scores well in vector space)
- **Verdict:** PASS
---
### Test 2: Conceptual Queries (vector advantage)
#### 2a: Natural language question
- **Input:** query="why do AI assistants forget things", search_type="vector"
- **Actual:** 5 results — Manual Testing Session, "Balance security and usability" observation, "Tools should match thought patterns" observation. Scores ~0.56-0.57
- **Vector advantage:** Found conceptually related content despite no exact keyword overlap
- **Verdict:** PASS
#### 2b: Same query, text search
- **Input:** query="why do AI assistants forget things", search_type="text"
- **Actual:** 1 result — "What is Basic Memory?" (likely matched on "AI" token)
- **Verdict:** PASS (demonstrates vector advantage — text barely matched)
#### 2c: Domain concept with no jargon
- **Input:** query="pricing strategy for cloud product", search_type="vector"
- **Actual:** 3 results — SPEC-16 MCP Cloud Service Consolidation, knowledge architecture observation, Visual Knowledge Spaces relation. Scores ~0.56-0.57
- **Verdict:** PASS (found cloud-related content conceptually)
#### 2d: Technical concept, long query
- **Input:** query="SQLite performance optimization WAL mode concurrent writes", search_type="vector"
- **Actual:** 3 results — SPEC-11 API Performance Optimization, Real-Time Updates with WebSockets, marketing status update. Scores ~0.55-0.58
- **Verdict:** PASS (found performance-related content)
---
### Test 3: Keyword Queries (FTS strength)
#### 3a: Exact term match — "OAuth authentication"
- **Text:** 3 results with high relevance (exact matches in titles)
- **Vector:** Same top results (keyword overlap helps vector too)
- **Verdict:** PASS — FTS and vector converge on keyword-rich queries
#### 3b: "OAuth" single keyword, hybrid mode
- **Input:** query="OAuth", search_type="hybrid"
- **Actual:** 5 results — Basic Memory Coding Guide, AI Collaboration Examples, SPEC-18, daily note, Manual Testing Session. FTS + vector blended. Scores ~0.016-0.032
- **Note:** Top hybrid result is "Basic Memory Coding Guide" not an OAuth-specific doc — suggests hybrid scoring may dilute strong FTS matches
- **Verdict:** PASS but hybrid ranking questionable for single-keyword queries
---
### Test 4: Hybrid Ranking
#### 4a: Hybrid vs vector on "OAuth authentication"
- **Hybrid with entity_types=["entity"]:** 5 results — RLS Implementation Lessons, Cloud Readiness Assessment, AUTH.md OAuth, Core Service Implementation, OAuth Rip-and-Replace. Scores ~0.016-0.023
- **Vector with entity_types=["entity"]:** 5 results — Core Service Implementation, SPEC-13 CLI Auth, Coding Guide, Authentication Service, ADR Production Auth. Scores ~0.55-0.60
- **Observation:** Hybrid surfaces different top results than vector-only. Hybrid found RLS and Cloud Readiness docs that vector didn't prioritize. Different ranking is expected from RRF fusion.
- **Verdict:** PASS — hybrid produces meaningfully different ranking
---
### Test 5: Result Types
#### 5a: Vector returns all result types
- **Input:** query="keeping AI context between sessions", search_type="vector"
- **Entities:** SPEC-18 AI Memory Management Tool (type=entity)
- **Relations:** Prompt Builder integrates_with (type=relation)
- **Observations:** "Translation layer is key" (type=observation), "Maintaining context across conversation boundaries" (type=observation)
- **Verdict:** PASS — all three types appear in vector results
#### 5b: Observations carry metadata
- **Observation result:** category="challenge", content="Maintaining context across conversation boundaries", from_entity="research/ai-knowledge-management-research"
- **Verdict:** PASS — category, content, from_entity, tags all present
#### 5c: Relations carry link info
- **Relation result:** relation_type="integrates_with", from_entity="development/features/prompt-builder...", to_entity (present but truncated in some)
- **Verdict:** PASS — relation metadata present
---
### Test 6: Filters + Vector Search
#### 6a: entity_types=["entity"] with vector
- **Input:** query="OAuth authentication", search_type="vector", entity_types=["entity"]
- **Actual:** 5 results, all type="entity" (Core Service Implementation, SPEC-13, Coding Guide, Authentication Service, ADR Auth)
- **Verdict:** PASS — filter correctly restricts to entities only
#### 6b: types=["note"] with vector
- **Input:** query="OAuth authentication", search_type="vector", types=["note"]
- **Actual:** Same 5 results (all have entity_type="note" in metadata)
- **Verdict:** PASS — types filter works with vector search
#### 6c: after_date with vector
- **Input:** query="OAuth authentication", search_type="vector", after_date="2025-06-01"
- **Actual:** 3 results — Core Service Implementation, Cloud Web App analysis observation, SPEC-13. Filtered out older OAuth docs.
- **Verdict:** PASS — date filter applied correctly
#### 6d: entity_types=["entity"] with hybrid
- **Input:** query="OAuth authentication", search_type="hybrid", entity_types=["entity"]
- **Actual:** 5 results, all type="entity" — RLS lessons, Cloud Readiness, AUTH.md OAuth, Core Service, OAuth Rip-and-Replace
- **Verdict:** PASS — filter works with hybrid mode too
#### 6e: types=["entity"] with vector (WRONG filter name)
- **Input:** query="OAuth authentication", search_type="vector", types=["entity"]
- **Actual:** 0 results
- **Note:** `types` filters by entity_type metadata (e.g., "note", "person"), NOT by SearchItemType. Using types=["entity"] looks for entity_type="entity" which few/no notes have. This is a UX confusion point — the param names are ambiguous.
- **Verdict:** PASS (correct behavior) but USABILITY ISSUE — easy to confuse types vs entity_types
---
### Test 7: Edge Cases
#### 7a: Single character query
- **Input:** query="x", search_type="vector"
- **Actual:** 3 results — "Self-contained application bundle" observation, Non-Markdown File Support relation, quick-win-tools entity. Scores ~0.57-0.59
- **Note:** Single character still produces an embedding and returns results. Quality is low/random as expected.
- **Verdict:** PASS (no crash, returns results)
#### 7b: Whitespace-only query
- **Input:** query=" ", search_type="vector"
- **Actual:** 0 results
- **Verdict:** PASS (handled gracefully — _check_vector_eligible strips and rejects empty)
#### 7c: Query with no relevant content
- **Input:** query="quantum computing blockchain", search_type="vector"
- **Actual:** 3 results — Inter-Agent Communication relation, Self-contained bundle observation, JSON-LD interop observation. Scores ~0.54
- **Note:** Still returns results because vector search always finds nearest neighbors. Scores are lower (~0.54) than relevant queries (~0.58-0.60). No relevance threshold applied.
- **Verdict:** PASS (expected behavior) but NOTE — no relevance cutoff means irrelevant queries always return something
---
### Test 8: Pagination
#### 8a: Vector search page 2
- **Input:** query="keeping AI context between sessions", search_type="vector", page=2, page_size=3
- **Actual:** 3 results on page 2, current_page=2. Different results from page 1. Top: "Maintaining context across conversation boundaries" observation (score 0.587)
- **Note:** Interestingly, page 2 had a higher-scoring result than some page 1 results. This may indicate pagination doesn't sort globally — it might be paginating within a pre-scored set.
- **Verdict:** PASS (pagination works) but POSSIBLE ISSUE — result ordering across pages needs investigation
---
## Summary
### Passing Tests: 20/21
### Bugs Found
1. **search_type="semantic" silently falls through** (Test 1a) — Invalid search_type values fall to the `else` branch and default to text search without any warning. Should either alias "semantic" to "vector" or raise an error.
### Usability Issues
2. **types vs entity_types confusion** (Test 6e) — `types` filters by entity_type metadata (note, person, etc.) while `entity_types` filters by SearchItemType (entity, observation, relation). The naming is ambiguous and easy to mix up.
3. **No relevance threshold** (Test 7c) — Vector search always returns nearest neighbors even for completely irrelevant queries. Consider adding a minimum score threshold or at least documenting expected score ranges.
4. **Hybrid ranking for single keywords** (Test 3b) — Hybrid mode on simple keyword queries produced less intuitive rankings than pure FTS or pure vector. The RRF fusion may dilute strong FTS signals.
### Observations
- Vector search successfully finds conceptually related content that FTS misses entirely
- Score ranges: relevant queries ~0.56-0.60, irrelevant queries ~0.54 (narrow spread)
- All three result types (entity, observation, relation) appear correctly in vector results
- Filters (entity_types, types, after_date) all work correctly with vector and hybrid modes
- Pagination works but cross-page ordering may need investigation
+270
View File
@@ -0,0 +1,270 @@
# Semantic Search
This guide covers Basic Memory's semantic (vector) search feature, which adds meaning-based retrieval alongside the existing full-text search.
## Overview
Basic Memory's search supports both full-text search (FTS) and semantic retrieval. Semantic search adds vector embeddings that capture the *meaning* of your content, enabling:
- **Paraphrase matching**: Find "authentication flow" when searching for "login process"
- **Conceptual queries**: Search for "ways to improve performance" and find notes about caching, indexing, and optimization
- **Hybrid retrieval**: Combine the precision of keyword search with the recall of semantic similarity
Semantic search is enabled by default when semantic dependencies are available at runtime. It works on both SQLite (local) and Postgres (cloud) backends.
## Installation
Semantic search dependencies (fastembed, sqlite-vec, openai) are included in the default `basic-memory` install.
```bash
pip install basic-memory
```
You can always override with `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true|false`.
### Platform Compatibility
| Platform | FastEmbed (local) | OpenAI (API) |
|---|---|---|
| macOS ARM64 (Apple Silicon) | Yes | Yes |
| macOS x86_64 (Intel Mac) | No — see workaround below | Yes |
| Linux x86_64 | Yes | Yes |
| Linux ARM64 | Yes | Yes |
| Windows x86_64 | Yes | Yes |
#### Intel Mac Workaround
The default install includes FastEmbed, which depends on ONNX Runtime. ONNX Runtime dropped Intel Mac (x86_64) wheels starting in v1.24, so install with a compatible ONNX Runtime pin first:
```bash
pip install basic-memory 'onnxruntime<1.24'
```
After installation, Intel Mac users have two runtime options:
**Option 1: Use OpenAI embeddings (recommended)**
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
export OPENAI_API_KEY=sk-...
```
**Option 2: Use FastEmbed locally**
Keep the same pinned installation and use FastEmbed (default provider):
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=fastembed
```
## Quick Start
1. Install Basic Memory:
```bash
pip install basic-memory
```
2. (Optional) Explicitly enable semantic search:
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
```
3. Build vector embeddings for your existing content:
```bash
bm reindex --embeddings
```
4. Search using semantic modes:
```python
# Pure vector similarity
search_notes("login process", search_type="vector")
# Hybrid: combines FTS precision with vector recall (recommended)
search_notes("login process", search_type="hybrid")
# Explicit full-text search
search_notes("login process", search_type="text")
```
## Configuration Reference
All settings are fields on `BasicMemoryConfig` and can be set via environment variables (prefixed with `BASIC_MEMORY_`).
| Config Field | Env Var | Default | Description |
|---|---|---|---|
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto (`true` when semantic deps are available) | Enable semantic search. Required before vector/hybrid modes work. |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
## Embedding Providers
### FastEmbed (default)
FastEmbed runs entirely locally using ONNX models — no API key, no network calls, no cost.
- **Model**: `BAAI/bge-small-en-v1.5`
- **Dimensions**: 384
- **Tradeoff**: Smaller model, fast inference, good quality for most use cases
```bash
# Install basic-memory and enable semantic search
pip install basic-memory
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
```
### OpenAI
Uses OpenAI's embeddings API for higher-dimensional vectors. Requires an API key.
- **Model**: `text-embedding-3-small`
- **Dimensions**: 1536
- **Tradeoff**: Higher quality embeddings, requires API calls and an OpenAI key
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
export OPENAI_API_KEY=sk-...
```
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
```bash
bm reindex --embeddings
```
## Search Modes
### `text` (default)
Full-text keyword search using FTS5 (SQLite) or tsvector (Postgres). Supports boolean operators (`AND`, `OR`, `NOT`), phrase matching, and prefix wildcards.
```python
search_notes("project AND planning", search_type="text")
```
This is the existing default and does not require semantic search to be enabled.
### `vector`
Pure semantic similarity search. Embeds your query and finds the nearest content vectors. Good for conceptual or paraphrase queries where exact keywords may not appear in the content.
```python
search_notes("how to speed up the app", search_type="vector")
```
Returns results ranked by cosine similarity. Individual observations and relations surface as first-class results, not collapsed into parent entities.
### `hybrid`
Combines FTS and vector results using score-based fusion. This is generally the best mode when you want both keyword precision and semantic recall.
```python
search_notes("authentication security", search_type="hybrid")
```
Score-based fusion uses the formula `max(vec, fts) + bonus * min(vec, fts)` to preserve the dominant signal while rewarding results found by both methods.
### When to Use Which
| Mode | Best For |
|---|---|
| `text` | Exact keyword matching, boolean queries, tag/category searches |
| `vector` | Conceptual queries, paraphrase matching, exploratory searches |
| `hybrid` | General-purpose search combining precision and recall |
## The Reindex Command
The `bm reindex` command rebuilds search indexes without dropping the database.
```bash
# Rebuild everything (FTS + embeddings if semantic is enabled)
bm reindex
# Only rebuild vector embeddings
bm reindex --embeddings
# Only rebuild the full-text search index
bm reindex --search
# Target a specific project
bm reindex -p my-project
```
### When You Need to Reindex
- **Upgrade note**: Migration now performs a one-time automatic embedding backfill on upgrade.
- **Manual enable case**: If you explicitly had `semantic_search_enabled=false` and then turn it on
- **Provider change**: After switching between `fastembed` and `openai`
- **Model change**: After changing `semantic_embedding_model`
- **Dimension change**: After changing `semantic_embedding_dimensions`
The reindex command shows progress with embedded/skipped/error counts:
```
Project: main
Building vector embeddings...
✓ Embeddings complete: 142 entities embedded, 0 skipped, 0 errors
Reindex complete!
```
## How It Works
### Chunking
Each entity in the search index is split into semantic chunks before embedding:
- **Headers**: Markdown headers (`#`, `##`, etc.) start new chunks
- **Bullets**: Each bullet item (`-`, `*`) becomes its own chunk for granular fact retrieval
- **Prose sections**: Non-bullet text is merged up to ~900 characters per chunk
- **Long sections**: Oversized content is split with ~120 character overlap to preserve context at boundaries
Each search index item type (entity, observation, relation) is chunked independently, so observations and relations are embeddable as discrete facts.
### Deduplication
Each chunk has a `source_hash` (SHA-256 of the chunk text). On re-sync, unchanged chunks skip re-embedding entirely. This makes incremental updates fast — only modified content triggers API calls or model inference.
### Hybrid Fusion
Hybrid search uses score-based fusion to merge FTS and vector results:
1. Run FTS search to get keyword-ranked results; normalize scores to [0, 1]
2. Run vector search to get similarity-ranked results (already [0, 1])
3. For each result, compute: `fused = max(vec_score, fts_score) + 0.3 * min(vec_score, fts_score)`
4. Sort by fused score
The dominant signal (whichever source scored higher) is preserved, and dual-source agreement adds a bonus. Unlike rank-based fusion, this approach retains score magnitude — a strong vector match stays strong even without an FTS hit.
### Observation-Level Results
Vector and hybrid modes return individual observations and relations as first-class search results, not just parent entities. This means a search for "water temperature for brewing" can surface the specific observation about 205°F without returning the entire "Coffee Brewing Methods" entity.
## Database Backends
### SQLite (local)
- **Vector storage**: [sqlite-vec](https://github.com/asg017/sqlite-vec) virtual table
- **Table creation**: At runtime when semantic search is first used — no migration needed
- **Embedding table**: `search_vector_embeddings` using `vec0(embedding float[N])` where N is the configured dimensions
- **Chunk metadata**: `search_vector_chunks` table stores chunk text, keys, and source hashes
The sqlite-vec extension is loaded per-connection. Vector tables are created lazily on first use.
### Postgres (cloud)
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
The Alembic migration creates the dimension-independent chunks table. The embeddings table and HNSW index are deferred to runtime because they depend on the configured vector dimensions.
+225
View File
@@ -0,0 +1,225 @@
# SPEC-LOCAL-PLUS-PUBLISH: Local+ Published Notes and Privacy Tiers
**Status:** Draft
**Date:** 2026-02-14
**Owner:** Basic Memory
## Summary
Add a paid Local+ feature that lets users publish selected notes to shareable URLs while keeping the
main knowledge base local-first. Use this as a product wedge for users who do not want full cloud
hosting but do want collaboration and distribution features.
This spec also captures a practical position on "zero knowledge" for Local+.
## Context
Basic Memory already has strong local-first primitives and optional cloud routing/sync. A recurring
request is:
- keep knowledge local by default,
- pay for selective value-add,
- share specific outputs externally.
Published Notes fits this model: explicit per-note opt-in, reversible, and easy to understand.
## Goals
1. Provide an Obsidian Publish-style sharing experience for selected notes.
2. Keep local markdown files as source of truth.
3. Make sharing compatible with current cloud/auth/billing primitives.
4. Define clear Local+ packaging that does not degrade OSS local workflows.
5. Document zero-knowledge constraints so product decisions are explicit.
## Non-Goals
1. Full hosted editing for all notes (Cloud Full remains separate).
2. Public website builder/CMS features.
3. Strict cryptographic zero-knowledge server processing for MCP/search in v1.
## Local+ Feature Catalog (Sellable)
Core Local+ candidates:
1. Published Notes (share URL, revoke, expiry, password).
2. Snapshot Time Machine (point-in-time restore for local projects).
3. Recovery Drill Reports (automated restore verification).
4. Device/API Key Governance (per-device keys, revocation, audit trail).
5. BYO Storage Orchestration (managed setup for user-owned object storage).
6. Semantic Boost Add-on (higher quality retrieval options while files remain source-of-truth).
Team-oriented add-ons:
1. Team-owned shared links and domain branding.
2. Role-based publish permissions.
3. Shared workspace policies for what can be published.
## Proposed MVP: Published Notes
### User Experience
Per note actions:
1. Publish.
2. Unpublish.
3. Copy URL.
4. Regenerate URL.
5. Set visibility and controls.
Controls:
1. Visibility: `unlisted` (default) or `public`.
2. Optional password gate.
3. Optional expiration datetime.
4. Optional "disable indexing" flag for public mode.
Behavior:
1. Source note remains local markdown.
2. Publish is explicit opt-in per note.
3. Unpublish removes public access immediately.
4. Republish creates a new URL token unless user chooses to keep current URL.
### URL Model
1. Unlisted share URL: high-entropy token path.
2. Public URL: slug path (optional, later phase).
3. Team plans can support custom domain mapping in later phase.
### Content Model
v1 published page includes:
1. Rendered markdown body.
2. Optional metadata (title, updated_at).
v1 excludes:
1. Full graph traversal expansion.
2. Related note auto-discovery on public pages.
### Sync Model
1. Local file remains canonical.
2. Publish stores a rendered snapshot plus metadata in cloud.
3. Update path:
- manual "update published version", or
- optional auto-update on note change (plan-gated).
## Architecture (v1)
### High-Level Flow
1. Client selects a note to publish.
2. Client sends publish request with note identifier and policy.
3. Service resolves note content (local sync artifact or explicit upload payload).
4. Service stores published artifact and returns share URL.
### Data Model
`published_notes`
1. `id` (uuid)
2. `tenant_id` or `workspace_id`
3. `project_id`
4. `entity_permalink` (or stable external_id)
5. `share_token` (hashed in DB)
6. `visibility` (`unlisted`|`public`)
7. `password_hash` (nullable)
8. `expires_at` (nullable)
9. `is_active`
10. `published_content` (rendered snapshot or reference)
11. `published_at`
12. `updated_at`
### API Shape (Draft)
1. `POST /api/published-notes`
2. `GET /api/published-notes`
3. `GET /api/published-notes/{id}`
4. `PATCH /api/published-notes/{id}`
5. `DELETE /api/published-notes/{id}` (unpublish)
6. `POST /api/published-notes/{id}/regenerate-url`
7. `GET /p/{token}` (public resolver)
### CLI Shape (Draft)
1. `bm cloud publish <identifier>`
2. `bm cloud publish list`
3. `bm cloud publish update <id>`
4. `bm cloud publish unpublish <id>`
5. `bm cloud publish rotate-url <id>`
### Security
1. Default to unlisted URLs.
2. Store only hashed share tokens.
3. Passwords hashed server-side.
4. Enforce expiration at request time.
5. Log publish/unpublish/rotate events for auditability.
## Packaging and Pricing Direction
Suggested split:
1. OSS Local: no publish URLs.
2. Local+ Solo: publish URLs + snapshots + recovery.
3. Local+ Team: solo features + team governance and branding.
4. Cloud Full: hosted app + full cloud workflows.
Key message:
"Keep everything local. Publish only what you choose."
## Rollout Plan
1. Phase 1: Unlisted publish URLs + unpublish + regenerate URL.
2. Phase 2: Password/expiry controls.
3. Phase 3: Auto-update on note change and basic analytics.
4. Phase 4: Team branding/domains/policies.
## Zero-Knowledge Position
### Strict Zero-Knowledge Definition
Strict zero-knowledge means the server cannot decrypt note content at all.
### Why This Conflicts with MCP and Search
If server cannot decrypt:
1. MCP tool execution against cloud content cannot read/write semantic content.
2. Full-text search cannot index plaintext content.
3. Semantic/vector search cannot generate or query embeddings on plaintext.
4. Server-side relation resolution and context building become severely limited.
This matches earlier findings: strict zero-knowledge materially handicaps MCP-driven behavior and
search quality.
### Viable Alternatives (Not Strict Zero-Knowledge)
1. Encryption at rest/in transit with server-side decrypt in trusted runtime.
- Preserves MCP/search quality.
- Not zero-knowledge cryptographically.
2. Client-side retrieval mode.
- Keep MCP/search local; cloud is sync/share/backup relay.
- Best for privacy-first users.
- Requires local agent availability for advanced retrieval.
3. Limited encrypted indexing.
- Blind indexes for exact keywords only.
- No high-quality semantic search.
- Usually poor UX for natural-language memory recall.
### Recommendation
For Local+:
1. Do not promise strict zero-knowledge for cloud MCP/search paths.
2. Offer a privacy-first local mode where advanced retrieval stays local.
3. Clearly label tradeoffs:
- "Local private mode" (best privacy, best local retrieval).
- "Cloud-assisted mode" (best cross-device/MCP consistency, trusted-runtime decrypt).
This keeps messaging honest and avoids repeating the known incompatibility.
+368
View File
@@ -0,0 +1,368 @@
# SPEC-SCHEMA-IMPL: Schema System Implementation Plan
**Status:** Draft
**Created:** 2025-02-06
**Branch:** `feature/schema-system`
**Depends on:** [SPEC-SCHEMA](SPEC-SCHEMA.md)
## Overview
Implementation plan for the Basic Memory Schema System. The system is entirely programmatic —
no LLM agent runtime or API key required. The LLM already in the user's session (Claude Code,
Claude Desktop, etc.) provides the intelligence layer by reading schema notes via existing
MCP tools.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Entry Points │
│ CLI (bm schema ...) │ MCP (schema_validate) │
└──────────┬────────────┴──────────┬──────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Schema Service Layer │
│ resolve_schema · validate · infer · diff │
└──────────┬────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ Picoschema Parser │ │ Note/Entity Access │
│ YAML → SchemaModel │ │ (existing repository) │
└──────────────────────┘ └────────────────────────┘
```
No new database tables. Schemas are notes with `type: schema` — they're already indexed.
Validation reads observations and relations from existing data.
## Components
### 1. Picoschema Parser
**Location:** `src/basic_memory/schema/parser.py`
Parses Picoschema YAML into an internal representation.
```python
@dataclass
class SchemaField:
name: str
type: str # string, integer, number, boolean, any, or EntityName
required: bool # True unless field name ends with ?
is_array: bool # True if (array) notation
is_enum: bool # True if (enum) notation
enum_values: list[str] # Populated for enums
description: str | None # Text after comma
is_entity_ref: bool # True if type is capitalized (entity reference)
children: list[SchemaField] # For (object) types
@dataclass
class SchemaDefinition:
entity: str # The entity type this schema describes
version: int # Schema version
fields: list[SchemaField] # Parsed fields
validation_mode: str # "warn" | "strict" | "off"
frontmatter_fields: list[SchemaField] # From settings.frontmatter (default: [])
def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
"""Parse a Picoschema YAML dict into a list of SchemaField objects."""
def parse_schema_note(frontmatter: dict) -> SchemaDefinition:
"""Parse a full schema note's frontmatter into a SchemaDefinition."""
```
**Input/Output:**
```yaml
# Input (YAML dict from frontmatter)
schema:
name: string, full name
role?: string, job title
works_at?: Organization, employer
expertise?(array): string, areas of knowledge
```
```python
# Output
[
SchemaField(name="name", type="string", required=True, description="full name", ...),
SchemaField(name="role", type="string", required=False, description="job title", ...),
SchemaField(name="works_at", type="Organization", required=False, is_entity_ref=True, ...),
SchemaField(name="expertise", type="string", required=False, is_array=True, ...),
]
```
### 2. Schema Resolver
**Location:** `src/basic_memory/schema/resolver.py`
Finds the applicable schema for a note using the resolution order.
```python
async def resolve_schema(
note_frontmatter: dict,
search_fn: Callable, # injected search capability
) -> SchemaDefinition | None:
"""Resolve schema for a note.
Resolution order:
1. Inline schema (frontmatter['schema'] is a dict)
2. Explicit reference (frontmatter['schema'] is a string)
3. Implicit by type (frontmatter['type'] → schema note with matching entity)
4. No schema (returns None)
"""
```
### 3. Schema Validator
**Location:** `src/basic_memory/schema/validator.py`
Validates a note's observations and relations against a resolved schema.
```python
@dataclass
class FieldResult:
field: SchemaField
status: str # "present" | "missing" | "type_mismatch"
values: list[str] # Matched observation values or relation targets
message: str | None # Human-readable detail
@dataclass
class ValidationResult:
note_identifier: str
schema_entity: str
passed: bool # True if no errors (warnings are OK)
field_results: list[FieldResult]
unmatched_observations: dict[str, int] # category → count
unmatched_relations: list[str] # relation types not in schema
warnings: list[str]
errors: list[str]
async def validate_note(
note: Note,
schema: SchemaDefinition,
frontmatter: dict | None = None,
) -> ValidationResult:
"""Validate a note against a schema definition.
Mapping rules:
- field: string → observation [field] exists
- field?(array): type → multiple [field] observations
- field?: EntityType → relation 'field [[...]]' exists
- field?(enum): [v] → observation [field] value ∈ enum values
- settings.frontmatter field → frontmatter key presence/value
"""
```
### 4. Schema Inference Engine
**Location:** `src/basic_memory/schema/inference.py`
Analyzes notes of a given type and suggests a schema based on usage frequency.
```python
@dataclass
class FieldFrequency:
name: str
source: str # "observation" | "relation"
count: int # notes containing this field
total: int # total notes analyzed
percentage: float
sample_values: list[str] # representative values
is_array: bool # True if typically appears multiple times per note
target_type: str | None # For relations, the most common target entity type
@dataclass
class InferenceResult:
entity_type: str
notes_analyzed: int
field_frequencies: list[FieldFrequency]
suggested_schema: dict # Ready-to-use Picoschema YAML dict
suggested_required: list[str]
suggested_optional: list[str]
excluded: list[str] # Below threshold
async def infer_schema(
entity_type: str,
notes: list[Note],
required_threshold: float = 0.95, # 95%+ = required
optional_threshold: float = 0.25, # 25%+ = optional
) -> InferenceResult:
"""Analyze notes and suggest a Picoschema definition."""
```
### 5. Schema Diff
**Location:** `src/basic_memory/schema/diff.py`
Compares current note usage against an existing schema definition.
```python
@dataclass
class SchemaDrift:
new_fields: list[FieldFrequency] # Fields not in schema but common in notes
dropped_fields: list[FieldFrequency] # Fields in schema but rare in notes
cardinality_changes: list[str] # one → many or many → one
type_mismatches: list[str] # observation values don't match declared type
async def diff_schema(
schema: SchemaDefinition,
notes: list[Note],
) -> SchemaDrift:
"""Compare a schema against actual note usage to detect drift."""
```
## Entry Points
### CLI Commands
**Location:** `src/basic_memory/cli/schema.py`
```python
import typer
schema_app = typer.Typer(name="schema", help="Schema management commands")
@schema_app.command()
async def validate(
target: str = typer.Argument(None, help="Note path or entity type"),
strict: bool = typer.Option(False, help="Override to strict mode"),
):
"""Validate notes against their schemas."""
@schema_app.command()
async def infer(
entity_type: str = typer.Argument(..., help="Entity type to analyze"),
threshold: float = typer.Option(0.25, help="Minimum frequency for optional fields"),
save: bool = typer.Option(False, help="Save to schema/ directory"),
):
"""Infer schema from existing notes of a type."""
@schema_app.command()
async def diff(
entity_type: str = typer.Argument(..., help="Entity type to diff"),
):
"""Show drift between schema and actual usage."""
```
Registered as subcommand: `bm schema validate`, `bm schema infer`, `bm schema diff`.
### MCP Tools
**Location:** `src/basic_memory/mcp/tools/schema.py`
```python
@mcp_tool
async def schema_validate(
entity_type: str | None = None,
identifier: str | None = None,
project: str | None = None,
) -> str:
"""Validate notes against their resolved schema."""
@mcp_tool
async def schema_infer(
entity_type: str,
threshold: float = 0.25,
project: str | None = None,
) -> str:
"""Analyze existing notes and suggest a schema definition."""
```
### API Endpoints
**Location:** `src/basic_memory/api/schema_router.py`
```python
router = APIRouter(prefix="/schema", tags=["schema"])
@router.post("/validate")
async def validate_schema(...) -> ValidationReport: ...
@router.post("/infer")
async def infer_schema(...) -> InferenceResult: ...
@router.get("/diff/{entity_type}")
async def diff_schema(...) -> SchemaDrift: ...
```
MCP tools call these endpoints via the typed client pattern (consistent with existing
architecture).
## Implementation Phases
### Phase 1: Parser + Resolver
Build the foundation — can parse Picoschema and find schemas for notes.
**Deliverables:**
- `schema/parser.py` — Picoschema YAML → `SchemaDefinition`
- `schema/resolver.py` — Resolution order (inline → explicit ref → implicit by type → none)
- Unit tests for all Picoschema syntax variations
- Unit tests for resolution order
**No external dependencies.** Pure Python parsing of YAML dicts. Can develop and test
in isolation.
### Phase 2: Validator
Connect schemas to notes and produce validation results.
**Deliverables:**
- `schema/validator.py` — Validate note observations/relations against schema fields
- API endpoint: `POST /schema/validate`
- MCP tool: `schema_validate`
- CLI command: `bm schema validate`
- Integration tests with real notes and schemas
**Depends on:** Phase 1 (parser + resolver)
### Phase 3: Inference
Analyze existing notes to suggest schemas.
**Deliverables:**
- `schema/inference.py` — Frequency analysis across notes of a type
- API endpoint: `POST /schema/infer`
- MCP tool: `schema_infer`
- CLI command: `bm schema infer`
- Option to save inferred schema as a note via `write_note`
**Depends on:** Phase 1 (parser for output format)
### Phase 4: Diff
Compare schemas against current usage.
**Deliverables:**
- `schema/diff.py` — Drift detection between schema and actual notes
- API endpoint: `GET /schema/diff/{entity_type}`
- CLI command: `bm schema diff`
**Depends on:** Phase 1 (parser), Phase 3 (inference, for frequency analysis)
## Testing Strategy
- **Unit tests** (`tests/schema/`): Parser edge cases, resolution logic, validation mapping,
inference thresholds
- **Integration tests** (`test-int/schema/`): End-to-end with real markdown files, schema notes
on disk, CLI invocation
- Coverage target: 100% (consistent with project standard)
## What This Does NOT Include
- No new database tables or migrations
- No new markdown syntax (schemas validate existing observations/relations)
- No LLM agent runtime or API key management
- No hook integration (deferred)
- No schema composition/inheritance (deferred)
- No OWL/RDF export (deferred)
- No built-in templates (deferred)
+492
View File
@@ -0,0 +1,492 @@
# SPEC-SCHEMA: Basic Memory Schema System
**Status:** Draft
**Created:** 2025-02-06
**Branch:** `feature/schema-system`
## Summary
A schema system for Basic Memory that uses [Picoschema](https://genkit.dev/docs/dotprompt/)
syntax in YAML frontmatter. Schemas validate notes against their existing observation/relation
structure — no new data model, no migration, just a declarative lens over what's already there.
## Core Principles
1. **Schemas are just notes** — A schema is a note with `type: schema`, lives anywhere
2. **Use prior art** — Picoschema syntax in YAML frontmatter, no custom notation
3. **Validation maps to existing format** — Observations and relations, not a parallel data model
4. **Validation is soft** — Warnings by default, not blocking errors
5. **Inference over prescription** — Schemas describe reality, emerge from usage
6. **No built-in agent** — Programmatic core; the LLM already in the session provides intelligence
## Picoschema Syntax
Picoschema is a compact schema notation from Google's Dotprompt that fits naturally in YAML
frontmatter.
### Supported Types
| Type | Description |
|------|-------------|
| `string` | Text value |
| `integer` | Whole number |
| `number` | Decimal number |
| `boolean` | True/false |
| `any` | Any scalar type |
| `EntityName` | Reference to another entity (capitalized = entity reference) |
### Syntax Rules
```yaml
schema:
name: string, full name # required field with description
email?: string, contact email # ? = optional
role?: string, job title
works_at?: Organization, employer # capitalized type = entity reference
tags?(array): string, categories # array of type
status?(enum): [active, inactive] # enum with allowed values
metadata?(object): # nested object
updated_at?: string
source?: string
```
- `field: type` — required field
- `field?: type` — optional field
- `field(array): type` — array of values
- `field?(enum): [values]` — enumeration
- `field?(object):` — nested object with sub-fields
- `, description` — description after comma
- `EntityName` as type (capitalized) — reference to another entity
## Schema-to-Note Mapping
Schemas validate against the existing Basic Memory note format. No new syntax for note
authors to learn.
### Mapping Rules
| Schema Declaration | Grounded In | Example Match |
|--------------------|-------------|---------------|
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (×N) |
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (×N) |
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
| `field?(enum): [values]` | Observation `[field] value` where value ∈ set | `- [status] active` |
| `settings.frontmatter` field | Frontmatter key presence/value | `tags: [python, ai]` |
### Key Insight
Schemas don't introduce a new way to store data. They describe the patterns already present
in observations and relations. A note doesn't have to change how it's written — the schema
just says "a good Person note has a `[name]` observation and a `works_at` relation."
## Schema Definition
### As a Dedicated Schema Note
```yaml
# schema/Person.md
---
title: Person
type: schema
entity: Person
version: 1
schema:
name: string, full name
email?: string, contact email
role?: string, job title
works_at?: Organization, employer
expertise?(array): string, areas of knowledge
settings:
validation: warn # warn | strict | off
frontmatter:
tags?(array): string, note categories
status?(enum): [draft, review, published]
---
# Person
A human individual in the knowledge graph.
Any documentation about this entity type goes here as prose.
```
Schema notes are regular Basic Memory notes. They show up in search, can have their own
observations and relations, and can be organized in any folder (though `schema/` is
the suggested convention).
### Inline Schema in a Note
Notes can carry their own schema directly:
```yaml
# meetings/2024-01-15-standup.md
---
title: Team Standup 2024-01-15
type: meeting
schema:
attendees(array): string, who was there
decisions(array): string, what was decided
action_items(array): string, follow-ups
blockers?(array): string, anything stuck
---
# Team Standup 2024-01-15
## Observations
- [attendees] Paul
- [attendees] Sarah
- [decisions] Ship v2 by Friday
- [action_items] Paul to review PR #42
- [blockers] Waiting on API credentials
```
Good for one-off structured notes or prototyping a schema before extracting it.
### Explicit Schema Reference
A note can reference a schema by entity name or permalink:
```yaml
# projects/basic-memory.md
---
title: Basic Memory
schema: SoftwareProject # by entity name
---
# research/llm-memory-patterns.md
---
title: LLM Memory Patterns
schema: schema/research-project # by permalink
---
```
Use cases:
- Note's `type` differs from the schema it should validate against
- Multiple schema variants exist for the same domain
- Applying structure to existing notes without changing their type
## Schema Resolution
When validating a note, schemas resolve in priority order:
```
1. Inline schema → schema: { ... } (dict in frontmatter)
2. Explicit ref → schema: Person (string in frontmatter)
3. Implicit by type → type: Person (lookup schema note with entity: Person)
4. No schema → no validation (perfectly fine)
```
```python
async def resolve_schema(note: Note) -> Schema | None:
schema_value = note.frontmatter.get('schema')
# 1. Inline schema (dict)
if isinstance(schema_value, dict):
return parse_picoschema(schema_value)
# 2. Explicit reference (string)
if isinstance(schema_value, str):
schema_note = await find_schema_note(schema_value)
if schema_note:
return parse_picoschema(schema_note.frontmatter['schema'])
# 3. Implicit by type
note_type = note.frontmatter.get('type')
if note_type:
results = await search_notes(f"type:schema entity:{note_type}")
if results:
return parse_picoschema(results[0].frontmatter['schema'])
# 4. No schema
return None
```
## Validation
### Modes
Configured in the schema's `settings.validation`:
| Mode | Behavior |
|------|----------|
| `off` | No validation |
| `warn` | Warnings in output, doesn't block (default) |
| `strict` | Errors that block sync, for CI/CD enforcement |
### Validation Output
For a note missing required fields:
```
$ bm schema validate people/ada-lovelace.md
⚠ Person schema validation:
- Missing required field: name (expected [name] observation)
- Missing optional field: role
- Missing optional field: works_at (no relation found)
Unmatched observations: [fact] ×2, [born] ×1
Unmatched relations: collaborated_with
```
"Unmatched" items are informational — observations and relations the schema doesn't cover.
They're valid. Schemas are a subset, not a straitjacket.
### Frontmatter Validation
Schema notes can declare validation rules for frontmatter keys under `settings.frontmatter`
using the same Picoschema syntax as the `schema` block:
```yaml
settings:
validation: warn
frontmatter:
tags?(array): string
status?(enum): [draft, review, published]
```
- Frontmatter rules use the same Picoschema key syntax (`?` for optional, `(enum)`, `(array)`)
- Only available on schema notes (inline schemas skip frontmatter validation)
- Checks key presence (required vs optional) and enum value membership
- Unmatched frontmatter keys not in the schema are silently ignored
- Missing required frontmatter keys produce a warning (or error in strict mode)
Example output for a missing required frontmatter key:
```
⚠ Person schema validation:
- Missing required frontmatter key: status
```
### Batch Validation
```
$ bm schema validate Person
Validating 30 notes against Person schema...
✓ people/paul-graham.md — all fields present
✓ people/rich-hickey.md — all fields present
⚠ people/ada-lovelace.md — missing: name
⚠ people/alan-kay.md — missing: name, role
✓ people/linus-torvalds.md — all fields present
...
Summary: 22/30 valid, 8 warnings, 0 errors
```
## Emerging Schemas
### The Problem with Traditional Schemas
Most schema systems require: define schema → create conforming content → fight the schema
when reality doesn't match. This is backwards. Knowledge grows organically.
### The Basic Memory Approach
```
Write notes freely → Patterns emerge → Crystallize into schema → Validate future notes
```
### Schema Inference
Generate schemas from existing notes by analyzing observation and relation frequency:
```
$ bm schema infer Person
Analyzing 30 notes with type: Person...
Observations found:
[name] 30/30 100% → name: string
[role] 27/30 90% → role?: string
[fact] 25/30 83% (generic — no single field)
[expertise] 18/30 60% → expertise?(array): string
[email] 8/30 27% → email?: string
[born] 6/30 20% (below threshold)
Relations found:
works_at 22/30 73% → works_at?: Organization
authored 11/30 37% → authored?(array): string
Suggested schema:
name: string, full name
role?: string, job title
expertise?(array): string, areas of knowledge
email?: string, contact email
works_at?: Organization, employer
Save to schema/Person.md? [y/n]
```
Frequency thresholds:
- 100% present → required field
- 25%+ present → optional field
- Below 25% → excluded from suggestion (but noted)
### Schema Drift Detection
Track how usage patterns shift over time:
```
$ bm schema diff Person
Schema drift detected:
+ expertise: now in 81% of notes (was 12%)
- department: dropped to 3% of notes
~ works_at: cardinality changed (one → many)
Update schema? [y/n/review]
```
## LLM Integration (AI Guidance)
No agent runtime or API key required. The LLM already in the session uses schemas as
context for note creation.
### Flow
1. User asks LLM to "write a note about Rich Hickey"
2. LLM determines `type: Person` is appropriate
3. LLM calls `search_notes("type:schema entity:Person")` → finds schema
4. LLM reads schema fields: required `name`, optional `role`, `works_at`, `expertise`
5. LLM calls `write_note` with observations and relations that satisfy the schema
The schema acts as a creation template. The LLM knows what a "complete" note looks like
without any custom agent infrastructure.
### MCP Tools
```python
@mcp_tool
async def schema_validate(
entity_type: str | None = None,
identifier: str | None = None,
project: str | None = None,
) -> ValidationReport:
"""Validate notes against their resolved schema.
Validates a specific note (by identifier) or all notes of a given type.
Returns warnings/errors based on the schema's validation mode.
"""
@mcp_tool
async def schema_infer(
entity_type: str,
threshold: float = 0.25,
project: str | None = None,
) -> SuggestedSchema:
"""Analyze existing notes and suggest a schema definition.
Examines observation categories and relation types across all notes
of the given type. Returns frequency analysis and suggested Picoschema.
"""
```
## CLI Commands
```bash
# Validate a specific note
bm schema validate people/ada-lovelace.md
# Validate all notes of a type
bm schema validate Person
# Validate everything with a schema
bm schema validate
# Infer schema from existing notes
bm schema infer Person
# Show schema drift from current definition
bm schema diff Person
# List all schema notes
bm search "type:schema"
```
## Examples
### Complete Person Workflow
**Schema:**
```yaml
# schema/Person.md
---
title: Person
type: schema
entity: Person
version: 1
schema:
name: string, full name
role?: string, job title or position
works_at?: Organization, employer
expertise?(array): string, areas of knowledge
email?: string, contact email
settings:
validation: warn
---
# Person
A human individual in the knowledge graph.
```
**Valid note:**
```yaml
# people/paul-graham.md
---
title: Paul Graham
type: Person
tags: [startups, essays, lisp]
---
# Paul Graham
## Observations
- [name] Paul Graham
- [role] Essayist and investor
- [expertise] Startups
- [expertise] Lisp
- [expertise] Essay writing
- [fact] Created Viaweb, the first web app
## Relations
- works_at [[Y Combinator]]
- authored [[Hackers and Painters]]
```
**Note with warnings:**
```yaml
# people/ada-lovelace.md
---
title: Ada Lovelace
type: Person
---
# Ada Lovelace
## Observations
- [fact] Wrote the first computer program
- [born] 1815
## Relations
- collaborated_with [[Charles Babbage]]
```
Validation: warns about missing required `[name]` observation. Everything else is optional
or unmatched (which is fine).
## Future Considerations (Deferred)
These are interesting but out of scope for the initial implementation:
- **Multiple schema inheritance** — `schema: [Person, Author]`
- **Hook integration** — Pre-write validation via the hooks system
- **OWL/RDF export** — `bm schema export --format owl`
- **SPARQL queries** — Schema-aware graph queries
- **Built-in templates** — `bm schema use gtd`, `bm schema use zettelkasten`
- **Schema versioning/migration** — Tracking breaking changes across versions
+40 -9
View File
@@ -2,7 +2,6 @@
# Install dependencies
install:
uv pip install -e ".[dev]"
uv sync
@echo ""
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
@@ -43,9 +42,9 @@ test-unit-sqlite:
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
# Run integration tests against SQLite (excludes semantic benchmarks — use just test-semantic)
test-int-sqlite:
uv run pytest -p pytest_mock -v --no-cov test-int
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)
@@ -56,10 +55,10 @@ test-int-postgres:
# 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_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov 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_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov 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)
@@ -99,18 +98,43 @@ postgres-migrate:
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
# Will be skipped automatically on non-Windows platforms
test-windows:
uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
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:
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
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:
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:
BASIC_MEMORY_ENV=test BASIC_MEMORY_BENCHMARK_OUTPUT=.benchmarks/semantic-quality.jsonl uv run pytest -p pytest_mock -v -s --no-cov -m semantic test-int/semantic/
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl
# Run semantic benchmarks (Postgres combos only)
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]
semantic-report *args:
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl {{args}}
# Compare two search benchmark JSONL outputs
# Usage:
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl --format markdown --show-missing
benchmark-compare baseline candidate *args:
uv run python test-int/compare_search_benchmarks.py "{{baseline}}" "{{candidate}}" --format table {{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:
uv run pytest -p pytest_mock -v --no-cov tests test-int
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int
# Generate HTML coverage report
coverage:
@@ -146,10 +170,14 @@ lint: fix
fix:
uv run ruff check --fix --unsafe-fixes src tests test-int
# Type check code
# Type check code (pyright)
typecheck:
uv run pyright
# Type check code (ty)
typecheck-ty:
uv run ty check src/
# Clean build artifacts and cache files
clean:
find . -type f -name '*.pyc' -delete
@@ -185,6 +213,9 @@ update-deps:
# Run all code quality checks and tests
check: lint format typecheck test
# Run all code quality checks and all test suites, including semantic benchmarks
check-all: lint format typecheck test test-semantic
# Generate Alembic migration with descriptive message
migration message:
cd src/basic_memory/alembic && alembic revision --autogenerate -m "{{message}}"
+9 -2
View File
@@ -29,7 +29,7 @@ dependencies = [
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
"fastmcp>=3.0.1,<4",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
@@ -44,9 +44,11 @@ dependencies = [
"sniffio>=1.3.1",
"anyio>=4.10.0",
"httpx>=0.28.0",
"fastembed>=0.7.4",
"sqlite-vec>=0.1.6",
"openai>=1.100.2",
]
[project.urls]
Homepage = "https://github.com/basicmachines-co/basic-memory"
Repository = "https://github.com/basicmachines-co/basic-memory"
@@ -72,6 +74,7 @@ markers = [
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
"smoke: Fast end-to-end smoke tests for MCP flows",
"semantic: Tests requiring semantic dependencies (fastembed, sqlite-vec, openai)",
]
[tool.ruff]
@@ -93,6 +96,9 @@ dev = [
"psycopg>=3.2.0",
"pyright>=1.1.408",
"pytest-testmon>=2.2.0",
"ty>=0.0.18",
"cst-lsp>=0.1.3",
"libcst>=1.8.6",
]
[tool.hatch.version]
@@ -111,6 +117,7 @@ ignore = ["test/"]
defineConstant = { DEBUG = true }
reportMissingImports = "error"
reportMissingTypeStubs = false
reportUnusedImport = "none"
pythonVersion = "3.12"
+2 -2
View File
@@ -6,12 +6,12 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.18.5",
"version": "0.19.2",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.18.5",
"version": "0.19.2",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.18.5"
__version__ = "0.19.2"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -0,0 +1,68 @@
"""Add Postgres semantic vector search tables (pgvector-aware, optional)
Revision ID: h1b2c3d4e5f6
Revises: d7e8f9a0b1c2
Create Date: 2026-02-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "h1b2c3d4e5f6"
down_revision: Union[str, None] = "d7e8f9a0b1c2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create Postgres vector chunk metadata table.
Trigger: database backend is PostgreSQL.
Why: search_vector_chunks stores text metadata with no vector-dimension
dependency, so it's safe in a migration. search_vector_embeddings (which
requires pgvector and a provider-specific dimension) is created at runtime
by PostgresSearchRepository._ensure_vector_tables(), mirroring the SQLite
pattern where vector tables are created dynamically.
Outcome: creates the dimension-independent chunks table. The embeddings
table + HNSW index are deferred to runtime.
"""
connection = op.get_bind()
if connection.dialect.name != "postgresql":
return
op.execute(
"""
CREATE TABLE IF NOT EXISTS search_vector_chunks (
id BIGSERIAL PRIMARY KEY,
entity_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
chunk_key TEXT NOT NULL,
chunk_text TEXT NOT NULL,
source_hash TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, entity_id, chunk_key)
)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_search_vector_chunks_project_entity
ON search_vector_chunks (project_id, entity_id)
"""
)
def downgrade() -> None:
"""Remove Postgres vector chunk/embedding tables.
Does not drop pgvector extension because other schema objects may depend on it.
"""
connection = op.get_bind()
if connection.dialect.name != "postgresql":
return
op.execute("DROP TABLE IF EXISTS search_vector_embeddings")
op.execute("DROP TABLE IF EXISTS search_vector_chunks")
@@ -0,0 +1,29 @@
"""Trigger automatic semantic embedding backfill during migration.
Revision ID: i2c3d4e5f6g7
Revises: h1b2c3d4e5f6
Create Date: 2026-02-19 00:00:00.000000
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = "i2c3d4e5f6g7"
down_revision: Union[str, None] = "h1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""No schema change.
Trigger: this revision is newly applied.
Why: db.run_migrations() detects this revision transition and runs the existing
sync_entity_vectors() pipeline to backfill semantic embeddings automatically.
Outcome: users no longer need to run `bm reindex --embeddings` after upgrading.
"""
def downgrade() -> None:
"""No-op downgrade."""
@@ -0,0 +1,164 @@
"""Rename entity_type column to note_type
Revision ID: j3d4e5f6g7h8
Revises: i2c3d4e5f6g7
Create Date: 2026-02-22 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = "j3d4e5f6g7h8"
down_revision: Union[str, None] = "i2c3d4e5f6g7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(connection, table_name: str) -> bool:
"""Check if a table exists (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text("SELECT 1 FROM information_schema.tables WHERE table_name = :table_name"),
{"table_name": table_name},
)
return result.fetchone() is not None
# SQLite
result = connection.execute(
text("SELECT 1 FROM sqlite_master WHERE type='table' AND name = :table_name"),
{"table_name": table_name},
)
return result.fetchone() is not None
def index_exists(connection, index_name: str) -> bool:
"""Check if an index exists (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
{"index_name": index_name},
)
return result.fetchone() is not None
# SQLite
result = connection.execute(
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
{"index_name": index_name},
)
return result.fetchone() is not None
def column_exists(connection, table: str, column: str) -> bool:
"""Check if a column exists in a table (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.fetchone() is not None
# SQLite
result = connection.execute(text(f"PRAGMA table_info({table})"))
columns = [row[1] for row in result]
return column in columns
def upgrade() -> None:
"""Rename entity_type → note_type on the entity table."""
connection = op.get_bind()
dialect = connection.dialect.name
# Skip if already migrated (idempotent)
if column_exists(connection, "entity", "note_type"):
return
if dialect == "postgresql":
# Postgres supports direct column rename
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
# Recreate the index with new name
op.execute("DROP INDEX IF EXISTS ix_entity_type")
op.execute("CREATE INDEX ix_note_type ON entity (note_type)")
else:
# SQLite 3.25.0+ supports ALTER TABLE RENAME COLUMN directly.
# Avoids batch_alter_table which fails on tables with generated columns
# (duplicate column name error when recreating the table).
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
# Recreate the index with new name
if index_exists(connection, "ix_entity_type"):
op.drop_index("ix_entity_type", table_name="entity")
op.create_index("ix_note_type", "entity", ["note_type"])
# Update search index metadata: rename entity_type → note_type in JSON
# This updates the stored metadata so search results use the new field name
# Guard: search_index may not exist on a fresh DB (created by an earlier migration)
if not table_exists(connection, "search_index"):
return
if dialect == "postgresql":
op.execute(
text("""
UPDATE search_index
SET metadata = metadata - 'entity_type' || jsonb_build_object('note_type', metadata->'entity_type')
WHERE metadata ? 'entity_type'
""")
)
else:
op.execute(
text("""
UPDATE search_index
SET metadata = json_set(
json_remove(metadata, '$.entity_type'),
'$.note_type',
json_extract(metadata, '$.entity_type')
)
WHERE json_extract(metadata, '$.entity_type') IS NOT NULL
""")
)
def downgrade() -> None:
"""Rename note_type → entity_type on the entity table."""
connection = op.get_bind()
dialect = connection.dialect.name
if dialect == "postgresql":
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
op.execute("DROP INDEX IF EXISTS ix_note_type")
op.execute("CREATE INDEX ix_entity_type ON entity (entity_type)")
else:
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
if index_exists(connection, "ix_note_type"):
op.drop_index("ix_note_type", table_name="entity")
op.create_index("ix_entity_type", "entity", ["entity_type"])
# Revert search index metadata
if not table_exists(connection, "search_index"):
return
if dialect == "postgresql":
op.execute(
text("""
UPDATE search_index
SET metadata = metadata - 'note_type' || jsonb_build_object('entity_type', metadata->'note_type')
WHERE metadata ? 'note_type'
""")
)
else:
op.execute(
text("""
UPDATE search_index
SET metadata = json_set(
json_remove(metadata, '$.note_type'),
'$.entity_type',
json_extract(metadata, '$.note_type')
)
WHERE json_extract(metadata, '$.note_type') IS NOT NULL
""")
)
@@ -0,0 +1,74 @@
"""Add created_by and last_updated_by columns to entity table.
Revision ID: k4e5f6g7h8i9
Revises: j3d4e5f6g7h8
Create Date: 2026-02-23 00:00:00.000000
These columns track which cloud user created and last modified each entity.
Both are nullable — NULL for local/CLI usage and existing entities.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = "k4e5f6g7h8i9"
down_revision: Union[str, None] = "j3d4e5f6g7h8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(connection, table: str, column: str) -> bool:
"""Check if a column exists in a table (idempotent migration support)."""
if connection.dialect.name == "postgresql":
result = connection.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.fetchone() is not None
else:
# SQLite
result = connection.execute(text(f"PRAGMA table_info({table})"))
columns = [row[1] for row in result]
return column in columns
def upgrade() -> None:
"""Add created_by and last_updated_by columns to entity table.
Both columns are nullable strings that store cloud user_profile_id UUIDs.
No data backfill — existing rows get NULL.
"""
connection = op.get_bind()
if not column_exists(connection, "entity", "created_by"):
op.add_column("entity", sa.Column("created_by", sa.String(), nullable=True))
if not column_exists(connection, "entity", "last_updated_by"):
op.add_column("entity", sa.Column("last_updated_by", sa.String(), nullable=True))
def downgrade() -> None:
"""Remove created_by and last_updated_by columns from entity table."""
connection = op.get_bind()
dialect = connection.dialect.name
if column_exists(connection, "entity", "last_updated_by"):
if dialect == "postgresql":
op.drop_column("entity", "last_updated_by")
else:
with op.batch_alter_table("entity") as batch_op:
batch_op.drop_column("last_updated_by")
if column_exists(connection, "entity", "created_by"):
if dialect == "postgresql":
op.drop_column("entity", "created_by")
else:
with op.batch_alter_table("entity") as batch_op:
batch_op.drop_column("created_by")
+2
View File
@@ -18,6 +18,7 @@ from basic_memory.api.v2.routers import (
directory_router as v2_directory,
prompt_router as v2_prompt,
importer_router as v2_importer,
schema_router as v2_schema,
)
from basic_memory.api.v2.routers.project_router import (
add_project,
@@ -84,6 +85,7 @@ app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")
# Legacy web app proxy paths (compat with /proxy/projects/projects)
-1
View File
@@ -47,7 +47,6 @@ class ApiContainer:
"""
config = ConfigManager().config
mode = resolve_runtime_mode(
cloud_mode_enabled=config.cloud_mode_enabled,
is_test_env=config.is_test_env,
)
return cls(config=config, mode=mode)
@@ -8,6 +8,7 @@ from basic_memory.api.v2.routers.resource_router import router as resource_route
from basic_memory.api.v2.routers.directory_router import router as directory_router
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
from basic_memory.api.v2.routers.importer_router import router as importer_router
from basic_memory.api.v2.routers.schema_router import router as schema_router
__all__ = [
"knowledge_router",
@@ -18,4 +19,5 @@ __all__ = [
"directory_router",
"prompt_router",
"importer_router",
"schema_router",
]
@@ -20,6 +20,7 @@ from basic_memory.deps import (
ProjectConfigV2ExternalDep,
AppConfigDep,
EntityRepositoryV2ExternalDep,
RelationRepositoryV2ExternalDep,
ProjectExternalIdPathDep,
TaskSchedulerDep,
FileServiceV2ExternalDep,
@@ -31,6 +32,9 @@ from basic_memory.schemas.v2 import (
EntityResolveRequest,
EntityResolveResponse,
EntityResponseV2,
GraphEdge,
GraphNode,
GraphResponse,
MoveEntityRequestV2,
MoveDirectoryRequestV2,
DeleteDirectoryRequestV2,
@@ -39,6 +43,67 @@ from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteRe
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
def _schedule_vector_sync_if_enabled(
*,
task_scheduler,
app_config,
entity_id: int,
project_id: int,
) -> None:
"""Schedule out-of-band vector sync only when semantic search is enabled."""
if app_config.semantic_search_enabled:
task_scheduler.schedule(
"sync_entity_vectors",
entity_id=entity_id,
project_id=project_id,
)
## Graph endpoint
@router.get("/graph", response_model=GraphResponse)
async def get_graph(
project_id: ProjectExternalIdPathDep,
entity_repository: EntityRepositoryV2ExternalDep,
relation_repository: RelationRepositoryV2ExternalDep,
) -> GraphResponse:
"""Return all entities and resolved relations for knowledge graph visualization.
Returns a flat node/edge structure optimized for rendering with graph libraries.
Only includes resolved relations (where to_id is not null).
"""
logger.info("API v2 request: get_graph")
# Fetch all entities for this project
entities = await entity_repository.find_all(use_load_options=False)
nodes = [
GraphNode(
external_id=entity.external_id,
title=entity.title,
note_type=entity.note_type,
file_path=entity.file_path,
)
for entity in entities
]
# Fetch all resolved relations (to_id is not null) with eager-loaded entities
relations = await relation_repository.find_all()
edges = [
GraphEdge(
from_id=relation.from_entity.external_id,
to_id=relation.to_entity.external_id,
relation_type=relation.relation_type,
)
for relation in relations
if relation.to_entity is not None
]
logger.info(f"API v2 response: graph with {len(nodes)} nodes and {len(edges)} edges")
return GraphResponse(nodes=nodes, edges=edges)
## Resolution endpoint
@@ -113,7 +178,7 @@ async def resolve_identifier(
resolution_method=resolution_method,
)
logger.info(
logger.debug(
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
)
@@ -169,6 +234,7 @@ async def create_entity(
search_service: SearchServiceV2ExternalDep,
task_scheduler: TaskSchedulerDep,
file_service: FileServiceV2ExternalDep,
app_config: AppConfigDep,
fast: bool = Query(
True, description="If true, write quickly and defer indexing to background tasks."
),
@@ -183,7 +249,7 @@ async def create_entity(
Created entity with generated external_id (UUID) and file content
"""
logger.info(
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
"API v2 request", endpoint="create_entity", note_type=data.note_type, title=data.title
)
if fast:
@@ -195,7 +261,13 @@ async def create_entity(
)
else:
entity = await entity_service.create_entity(data)
await search_service.index_entity(entity, background_tasks=background_tasks)
await search_service.index_entity(entity)
_schedule_vector_sync_if_enabled(
task_scheduler=task_scheduler,
app_config=app_config,
entity_id=entity.id,
project_id=project_id,
)
result = EntityResponseV2.model_validate(entity)
if fast:
@@ -225,6 +297,7 @@ async def update_entity_by_id(
entity_repository: EntityRepositoryV2ExternalDep,
task_scheduler: TaskSchedulerDep,
file_service: FileServiceV2ExternalDep,
app_config: AppConfigDep,
entity_id: str = Path(..., description="Entity external ID (UUID)"),
fast: bool = Query(
True, description="If true, write quickly and defer indexing to background tasks."
@@ -277,7 +350,13 @@ async def update_entity_by_id(
)
response.status_code = 201
await search_service.index_entity(entity, background_tasks=background_tasks)
await search_service.index_entity(entity)
_schedule_vector_sync_if_enabled(
task_scheduler=task_scheduler,
app_config=app_config,
entity_id=entity.id,
project_id=project_id,
)
result = EntityResponseV2.model_validate(entity)
if fast:
@@ -303,6 +382,7 @@ async def edit_entity_by_id(
entity_repository: EntityRepositoryV2ExternalDep,
task_scheduler: TaskSchedulerDep,
file_service: FileServiceV2ExternalDep,
app_config: AppConfigDep,
entity_id: str = Path(..., description="Entity external ID (UUID)"),
fast: bool = Query(
True, description="If true, write quickly and defer indexing to background tasks."
@@ -359,7 +439,13 @@ async def edit_entity_by_id(
expected_replacements=data.expected_replacements,
)
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
await search_service.index_entity(updated_entity)
_schedule_vector_sync_if_enabled(
task_scheduler=task_scheduler,
app_config=app_config,
entity_id=updated_entity.id,
project_id=project_id,
)
result = EntityResponseV2.model_validate(updated_entity)
if fast:
@@ -434,6 +520,7 @@ async def move_entity(
project_config: ProjectConfigV2ExternalDep,
app_config: AppConfigDep,
search_service: SearchServiceV2ExternalDep,
task_scheduler: TaskSchedulerDep,
entity_id: str = Path(..., description="Entity external ID (UUID)"),
) -> EntityResponseV2:
"""Move an entity to a new file location.
@@ -472,7 +559,13 @@ async def move_entity(
# Reindex at new location
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
if reindexed_entity:
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
await search_service.index_entity(reindexed_entity)
_schedule_vector_sync_if_enabled(
task_scheduler=task_scheduler,
app_config=app_config,
entity_id=reindexed_entity.id,
project_id=project_id,
)
result = EntityResponseV2.model_validate(moved_entity)
@@ -499,6 +592,7 @@ async def move_directory(
project_config: ProjectConfigV2ExternalDep,
app_config: AppConfigDep,
search_service: SearchServiceV2ExternalDep,
task_scheduler: TaskSchedulerDep,
) -> DirectoryMoveResult:
"""Move all entities in a directory to a new location.
@@ -530,7 +624,13 @@ async def move_directory(
for file_path in result.moved_files:
entity = await entity_service.link_resolver.resolve_link(file_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
await search_service.index_entity(entity)
_schedule_vector_sync_if_enabled(
task_scheduler=task_scheduler,
app_config=app_config,
entity_id=entity.id,
project_id=project_id,
)
logger.info(
f"API v2 response: move_directory "
@@ -48,7 +48,7 @@ async def list_projects(
A list of all projects with metadata
"""
projects = await project_service.list_projects()
default_project = project_service.default_project
default_project = await project_service.get_default_project_name()
project_items = [
ProjectItem(
@@ -145,14 +145,14 @@ async def create_resource(
# Determine file details
file_name = PathLib(data.file_path).name
content_type = file_service.content_type(data.file_path)
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
note_type = "canvas" if data.file_path.endswith(".canvas") else "file"
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
note_type=note_type,
content_type=content_type,
file_path=data.file_path,
checksum=checksum,
@@ -253,14 +253,14 @@ async def update_resource(
# Determine file details
file_name = PathLib(target_file_path).name
content_type = file_service.content_type(target_file_path)
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
note_type = "canvas" if target_file_path.endswith(".canvas") else "file"
# Update entity using internal ID
updated_entity = await entity_repository.update(
entity.id,
{
"title": file_name,
"entity_type": entity_type,
"note_type": note_type,
"content_type": content_type,
"file_path": target_file_path,
"checksum": checksum,
@@ -0,0 +1,412 @@
"""V2 router for schema operations.
Provides endpoints for schema validation, inference, and drift detection.
The schema system validates notes against Picoschema definitions without
introducing any new data model -- it works entirely with existing
observations and relations.
Flow: Entity loaded with eager observations/relations -> convert to tuples -> core functions.
"""
from pathlib import Path as FilePath
import frontmatter
from fastapi import APIRouter, Path, Query
from loguru import logger
from basic_memory.deps import (
EntityRepositoryV2ExternalDep,
FileServiceV2ExternalDep,
LinkResolverV2ExternalDep,
)
from basic_memory.models.knowledge import Entity
from basic_memory.schemas.schema import (
ValidationReport,
InferenceReport,
DriftReport,
NoteValidationResponse,
FieldResultResponse,
FieldFrequencyResponse,
DriftFieldResponse,
)
from basic_memory.schema.resolver import resolve_schema
from basic_memory.schema.validator import validate_note
from basic_memory.schema.inference import infer_schema, NoteData, ObservationData, RelationData
from basic_memory.schema.diff import diff_schema
from basic_memory.utils import generate_permalink
# Note: No prefix here -- it's added during registration as /v2/{project_id}/schema
router = APIRouter(tags=["schema"])
# --- ORM to core data conversion ---
def _entity_observations(entity: Entity) -> list[ObservationData]:
"""Extract ObservationData from an entity's observations."""
return [ObservationData(obs.category, obs.content) for obs in entity.observations]
def _entity_relations(entity: Entity) -> list[RelationData]:
"""Extract RelationData from an entity's outgoing relations.
Carries the target entity's type on each relation so the inference engine
can suggest correct types (e.g. works_at -> Organization, not the source type).
"""
return [
RelationData(
relation_type=rel.relation_type,
target_name=rel.to_name,
target_note_type=rel.to_entity.note_type if rel.to_entity else None,
)
for rel in entity.outgoing_relations
]
def _entity_to_note_data(entity: Entity) -> NoteData:
"""Convert an ORM Entity to a NoteData for inference/diff analysis."""
return NoteData(
identifier=entity.permalink or entity.file_path,
observations=_entity_observations(entity),
relations=_entity_relations(entity),
)
def _entity_frontmatter(entity: Entity) -> dict:
"""Build a frontmatter dict from an entity's database metadata.
Used for the notes being validated — their type and schema ref are
unlikely to change between syncs.
"""
fm = dict(entity.entity_metadata) if entity.entity_metadata else {}
if entity.note_type:
fm.setdefault("type", entity.note_type)
return fm
async def _schema_frontmatter_from_file(
file_service: FileServiceV2ExternalDep,
entity: Entity,
) -> dict:
"""Read a schema entity's frontmatter directly from its file.
Schema definitions (field declarations, validation mode) are the source
of truth for validation. Reading from the file ensures schema-validate
always uses the latest settings, even when the file watcher hasn't
synced changes to entity_metadata in the database.
"""
try:
content = await file_service.read_file_content(entity.file_path)
post = frontmatter.loads(content)
metadata = dict(post.metadata)
# Trigger: file is mid-edit and missing required schema fields
# Why: parse_schema_note() raises ValueError for missing entity/schema,
# which would turn validation into a 500 response
# Outcome: fall back to last-known-good database metadata
if not metadata.get("entity") or not isinstance(metadata.get("schema"), dict):
logger.warning(
"Schema file has incomplete frontmatter, falling back to database metadata",
file_path=entity.file_path,
)
return _entity_frontmatter(entity)
return metadata
except Exception:
# Trigger: file is missing, unreadable, or has malformed frontmatter
# Why: fall back to database metadata rather than failing validation entirely
# Outcome: behaves like before this change — uses potentially stale data
logger.warning(
"Failed to read schema file, falling back to database metadata",
file_path=entity.file_path,
)
return _entity_frontmatter(entity)
# --- Validation ---
@router.post("/schema/validate", response_model=ValidationReport)
async def validate_schema(
entity_repository: EntityRepositoryV2ExternalDep,
file_service: FileServiceV2ExternalDep,
link_resolver: LinkResolverV2ExternalDep,
project_id: str = Path(..., description="Project external UUID"),
note_type: str | None = Query(None, description="Note type to validate"),
identifier: str | None = Query(None, description="Specific note identifier"),
):
"""Validate notes against their resolved schemas.
Validates a specific note (by identifier) or all notes of a given type.
Returns warnings/errors based on the schema's validation mode.
Schema definitions are read directly from their files to ensure the
latest settings (validation mode, field declarations) are always used,
even when file changes haven't been synced to the database yet.
"""
results: list[NoteValidationResponse] = []
# --- Single note validation ---
if identifier:
# Resolve identifier flexibly (permalink, title, path, fuzzy)
# to match how read_note and other tools resolve identifiers
entity = await link_resolver.resolve_link(identifier)
if not entity:
return ValidationReport(note_type=note_type, total_notes=0, total_entities=0)
frontmatter = _entity_frontmatter(entity)
schema_ref = frontmatter.get("schema")
async def search_fn(query: str) -> list[dict]:
entities = await _find_schema_entities(
entity_repository,
query,
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
)
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
schema_def = await resolve_schema(frontmatter, search_fn)
if schema_def:
result = validate_note(
entity.title or entity.permalink or identifier,
schema_def,
_entity_observations(entity),
_entity_relations(entity),
frontmatter=frontmatter,
)
results.append(_to_note_validation_response(result))
return ValidationReport(
note_type=note_type or entity.note_type,
total_notes=len(results),
total_entities=1,
valid_count=1 if (results and results[0].passed) else 0,
warning_count=sum(len(r.warnings) for r in results),
error_count=sum(len(r.errors) for r in results),
results=results,
)
# --- Batch validation by note type ---
entities = await _find_by_note_type(entity_repository, note_type) if note_type else []
for entity in entities:
frontmatter = _entity_frontmatter(entity)
schema_ref = frontmatter.get("schema")
async def search_fn(query: str) -> list[dict]:
entities = await _find_schema_entities(
entity_repository,
query,
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
)
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
schema_def = await resolve_schema(frontmatter, search_fn)
if schema_def:
result = validate_note(
entity.title or entity.permalink or entity.file_path,
schema_def,
_entity_observations(entity),
_entity_relations(entity),
frontmatter=frontmatter,
)
results.append(_to_note_validation_response(result))
valid = sum(1 for r in results if r.passed)
return ValidationReport(
note_type=note_type,
total_notes=len(results),
total_entities=len(entities),
valid_count=valid,
warning_count=sum(len(r.warnings) for r in results),
error_count=sum(len(r.errors) for r in results),
results=results,
)
# --- Inference ---
@router.post("/schema/infer", response_model=InferenceReport)
async def infer_schema_endpoint(
entity_repository: EntityRepositoryV2ExternalDep,
project_id: str = Path(..., description="Project external UUID"),
note_type: str = Query(..., description="Note type to analyze"),
threshold: float = Query(0.25, description="Minimum frequency for optional fields"),
):
"""Infer a schema from existing notes of a given type.
Examines observation categories and relation types across all notes
of the given type. Returns frequency analysis and suggested Picoschema.
"""
entities = await _find_by_note_type(entity_repository, note_type)
notes_data = [_entity_to_note_data(entity) for entity in entities]
result = infer_schema(note_type, notes_data, optional_threshold=threshold)
return InferenceReport(
note_type=result.note_type,
notes_analyzed=result.notes_analyzed,
field_frequencies=[
FieldFrequencyResponse(
name=f.name,
source=f.source,
count=f.count,
total=f.total,
percentage=f.percentage,
sample_values=f.sample_values,
is_array=f.is_array,
target_type=f.target_type,
)
for f in result.field_frequencies
],
suggested_schema=result.suggested_schema,
suggested_required=result.suggested_required,
suggested_optional=result.suggested_optional,
excluded=result.excluded,
)
# --- Drift Detection ---
@router.get("/schema/diff/{note_type}", response_model=DriftReport)
async def diff_schema_endpoint(
entity_repository: EntityRepositoryV2ExternalDep,
file_service: FileServiceV2ExternalDep,
note_type: str = Path(..., description="Note type to check for drift"),
project_id: str = Path(..., description="Project external UUID"),
):
"""Show drift between a schema definition and actual note usage.
Compares the existing schema for an entity type against how notes
of that type are actually structured. Identifies new fields, dropped
fields, and cardinality changes.
"""
async def search_fn(query: str) -> list[dict]:
entities = await _find_schema_entities(entity_repository, query)
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
# Resolve schema by note type
schema_frontmatter = {"type": note_type}
schema_def = await resolve_schema(schema_frontmatter, search_fn)
if not schema_def:
return DriftReport(note_type=note_type, schema_found=False)
# Collect all notes of this type
entities = await _find_by_note_type(entity_repository, note_type)
notes_data = [_entity_to_note_data(entity) for entity in entities]
result = diff_schema(schema_def, notes_data)
return DriftReport(
note_type=note_type,
new_fields=[
DriftFieldResponse(
name=f.name,
source=f.source,
count=f.count,
total=f.total,
percentage=f.percentage,
)
for f in result.new_fields
],
dropped_fields=[
DriftFieldResponse(
name=f.name,
source=f.source,
count=f.count,
total=f.total,
percentage=f.percentage,
)
for f in result.dropped_fields
],
cardinality_changes=result.cardinality_changes,
)
# --- Helpers ---
async def _find_by_note_type(
entity_repository: EntityRepositoryV2ExternalDep,
note_type: str,
) -> list[Entity]:
"""Find all entities of a given type using the repository's select pattern."""
query = entity_repository.select().where(Entity.note_type == note_type)
result = await entity_repository.execute_query(query)
return list(result.scalars().all())
async def _find_schema_entities(
entity_repository: EntityRepositoryV2ExternalDep,
target_note_type: str,
*,
allow_reference_match: bool = False,
) -> list[Entity]:
"""Find schema entities for resolver lookups.
Resolution strategy:
1) Always try exact entity_metadata['entity'] match (for implicit type lookup
and explicit references that use entity names)
2) Only when allow_reference_match=True and no entity match was found, try
exact reference matching by title/permalink (explicit schema references)
"""
query = entity_repository.select().where(Entity.note_type == "schema")
result = await entity_repository.execute_query(query)
entities = list(result.scalars().all())
normalized_target = generate_permalink(target_note_type)
entity_matches = [
e
for e in entities
if e.entity_metadata
and isinstance(e.entity_metadata.get("entity"), str)
and generate_permalink(e.entity_metadata["entity"]) == normalized_target
]
if entity_matches:
return entity_matches
if not allow_reference_match:
return []
reference_matches: list[Entity] = []
for entity in entities:
candidate_refs: list[str] = []
if entity.title:
candidate_refs.append(entity.title)
if entity.permalink:
candidate_refs.append(entity.permalink)
candidate_refs.append(FilePath(entity.permalink).name)
if any(generate_permalink(ref) == normalized_target for ref in candidate_refs):
reference_matches.append(entity)
return reference_matches
def _to_note_validation_response(result) -> NoteValidationResponse:
"""Convert a core ValidationResult to a Pydantic response model."""
return NoteValidationResponse(
note_identifier=result.note_identifier,
schema_entity=result.schema_entity,
passed=result.passed,
field_results=[
FieldResultResponse(
field_name=fr.field.name,
field_type=fr.field.type,
required=fr.field.required,
status=fr.status,
values=fr.values,
message=fr.message,
)
for fr in result.field_results
],
unmatched_observations=result.unmatched_observations,
unmatched_relations=result.unmatched_relations,
warnings=result.warnings,
errors=result.errors,
)
@@ -4,9 +4,13 @@ This router uses external_id UUIDs for stable, API-friendly routing.
V1 uses string-based project names which are less efficient and less stable.
"""
from fastapi import APIRouter, Path
from fastapi import APIRouter, HTTPException, Path
from basic_memory.api.v2.utils import to_search_results
from basic_memory.repository.semantic_errors import (
SemanticDependenciesMissingError,
SemanticSearchDisabledError,
)
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.deps import (
SearchServiceV2ExternalDep,
@@ -43,14 +47,28 @@ async def search(
Returns:
SearchResponse with paginated search results
"""
limit = page_size
offset = (page - 1) * page_size
results = await search_service.search(query, limit=limit, offset=offset)
# Fetch one extra item to detect whether more pages exist (N+1 trick)
fetch_limit = page_size + 1
try:
results = await search_service.search(query, limit=fetch_limit, offset=offset)
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
has_more = len(results) > page_size
if has_more:
results = results[:page_size]
search_results = await to_search_results(entity_service, results)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
has_more=has_more,
)
+2
View File
@@ -146,6 +146,7 @@ async def to_graph_context(
metadata=metadata,
page=page,
page_size=page_size,
has_more=context_result.metadata.has_more,
)
@@ -176,6 +177,7 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
score=r.score, # pyright: ignore
entity=entities[0].permalink if entities else None,
content=r.content,
matched_chunk=r.matched_chunk_text,
file_path=r.file_path,
metadata=r.metadata,
entity_id=entity_id,
+114
View File
@@ -0,0 +1,114 @@
"""Lightweight CLI analytics via Umami event collector.
Sends anonymous, non-blocking usage events to help understand how the
CLI-to-cloud conversion funnel performs. No PII, no fingerprinting,
no cookies. Respects the same opt-out mechanisms as promo messaging.
Events are fire-and-forget — analytics never blocks or breaks the CLI.
Defaults point to the Basic Memory Umami Cloud instance. Override via:
BASIC_MEMORY_UMAMI_HOST — Custom Umami instance URL
BASIC_MEMORY_UMAMI_SITE_ID — Custom Website ID
Opt out entirely with BASIC_MEMORY_NO_PROMOS=1.
"""
import json
import os
import threading
import urllib.request
from typing import Optional
import basic_memory
# ---------------------------------------------------------------------------
# Configuration — defaults baked in, overridable via environment
# ---------------------------------------------------------------------------
_DEFAULT_UMAMI_HOST = "https://api-gateway.umami.dev"
_DEFAULT_UMAMI_SITE_ID = "f6479898-ebaf-4e60-bce2-6dc60a3f6c5c"
def _umami_host() -> Optional[str]:
return os.getenv("BASIC_MEMORY_UMAMI_HOST", "").strip() or _DEFAULT_UMAMI_HOST
def _umami_site_id() -> Optional[str]:
return os.getenv("BASIC_MEMORY_UMAMI_SITE_ID", "").strip() or _DEFAULT_UMAMI_SITE_ID
def _analytics_disabled() -> bool:
"""True when analytics should not fire."""
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
return value in {"1", "true", "yes"}
def _is_configured() -> bool:
"""True when both host and site ID are available."""
return _umami_host() is not None and _umami_site_id() is not None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
# Well-known event names for the promo/cloud funnel
EVENT_PROMO_SHOWN = "cli-promo-shown"
EVENT_PROMO_OPTED_OUT = "cli-promo-opted-out"
EVENT_CLOUD_LOGIN_STARTED = "cli-cloud-login-started"
EVENT_CLOUD_LOGIN_SUCCESS = "cli-cloud-login-success"
EVENT_CLOUD_LOGIN_SUB_REQUIRED = "cli-cloud-login-sub-required"
def track(event_name: str, data: Optional[dict] = None) -> None:
"""Send an analytics event to Umami. Non-blocking, silent on failure.
Parameters
----------
event_name:
Short kebab-case name (e.g. "cli-promo-shown").
data:
Optional dict of event properties (all values should be strings/numbers).
"""
if _analytics_disabled() or not _is_configured():
return
host = _umami_host()
site_id = _umami_site_id()
# Umami v2 /api/send requires "type" at top level alongside "payload"
payload = {
"type": "event",
"payload": {
"hostname": "cli.basicmemory.com",
"language": "en",
"url": f"/cli/{event_name}",
"website": site_id,
"name": event_name,
"data": {
"version": basic_memory.__version__,
**(data or {}),
},
},
}
def _send():
try:
req = urllib.request.Request(
f"{host}/api/send",
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
# Umami's bot detection rejects non-browser User-Agents
"User-Agent": "Mozilla/5.0 (compatible; BasicMemoryCLI/"
f"{basic_memory.__version__})",
},
)
urllib.request.urlopen(req, timeout=3)
except Exception:
pass # Never break the CLI for analytics
# Non-daemon so the process waits for the request to complete.
# The 3s urllib timeout caps the worst-case exit delay.
t = threading.Thread(target=_send)
t.start()
+22 -1
View File
@@ -9,6 +9,7 @@ from typing import Optional # noqa: E402
import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
@@ -46,11 +47,31 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Trigger: first-run init confirmation before command output.
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
# Outcome: one-time plain line printed before the subcommand runs.
maybe_show_init_line(ctx.invoked_subcommand)
# Trigger: register promo as a post-command callback.
# Why: promo output should appear after the command's own output, not before.
# Outcome: promo panel renders below the command results (status tree, table, etc.).
ctx.call_on_close(lambda: maybe_show_cloud_promo(ctx.invoked_subcommand))
# Run initialization for commands that don't use the API
# 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_init_commands = {"doctor", "mcp", "status", "sync", "project", "tool", "reset"}
skip_init_commands = {
"doctor",
"mcp",
"status",
"sync",
"project",
"tool",
"reset",
"reindex",
"watch",
}
if (
not version
and ctx.invoked_subcommand is not None
+9 -1
View File
@@ -1,7 +1,14 @@
"""CLI commands for basic-memory."""
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project, format
from . import (
import_claude_projects,
import_chatgpt,
tool,
project,
format,
schema,
)
__all__ = [
"status",
@@ -15,4 +22,5 @@ __all__ = [
"tool",
"project",
"format",
"schema",
]
@@ -6,11 +6,14 @@ from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
from basic_memory.cli.commands.cloud.project_sync import * # noqa: F401,F403
# Register snapshot sub-command group
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
from basic_memory.cli.commands.cloud.workspace import workspace_app
cloud_app.add_typer(snapshot_app, name="snapshot")
cloud_app.add_typer(workspace_app, name="workspace")
# Register restore command (directly on cloud_app via decorator)
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
@@ -52,7 +52,7 @@ async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, st
auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
token = await auth_obj.get_valid_token()
if not token:
console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
console.print("[red]Not authenticated. Please run 'bm cloud login' first.[/red]")
raise typer.Exit(1)
return {"Authorization": f"Bearer {token}"}
@@ -6,6 +6,14 @@ from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.auth import CLIAuth
from basic_memory.cli.analytics import (
track,
EVENT_CLOUD_LOGIN_STARTED,
EVENT_CLOUD_LOGIN_SUCCESS,
EVENT_CLOUD_LOGIN_SUB_REQUIRED,
EVENT_PROMO_OPTED_OUT,
)
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
from basic_memory.config import ConfigManager
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
@@ -29,9 +37,10 @@ console = Console()
@cloud_app.command()
def login():
"""Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
async def _login():
track(EVENT_CLOUD_LOGIN_STARTED)
client_id, domain, host_url = get_cloud_config()
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
@@ -45,18 +54,17 @@ def login():
console.print("[dim]Verifying subscription access...[/dim]")
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
# Enable cloud mode after successful login and subscription validation
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_mode = True
config_manager.save_config(config)
console.print("[green]Cloud mode enabled[/green]")
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
track(EVENT_CLOUD_LOGIN_SUCCESS)
console.print("[green]Cloud authentication successful[/green]")
console.print(f"[dim]Cloud host ready: {host_url}[/dim]")
except SubscriptionRequiredError as e:
track(EVENT_CLOUD_LOGIN_SUB_REQUIRED)
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] (20% off for 3 months)\n"
)
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
"[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
@@ -68,71 +76,56 @@ def login():
@cloud_app.command()
def logout():
"""Disable cloud mode and return to local mode."""
# Disable cloud mode
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_mode = False
config_manager.save_config(config)
console.print("[green]Cloud mode disabled[/green]")
console.print("[dim]All CLI commands now work locally[/dim]")
"""Remove stored OAuth tokens."""
config = ConfigManager().config
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
auth.logout()
console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]")
@cloud_app.command("status")
def status() -> None:
"""Check cloud mode status and cloud instance health."""
# Check cloud mode
"""Check cloud authentication and connection status."""
config_manager = ConfigManager()
config = config_manager.load_config()
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
tokens = auth.load_tokens()
console.print("[bold blue]Cloud Mode Status[/bold blue]")
if config.cloud_mode:
console.print(" Mode: [green]Cloud (enabled)[/green]")
console.print(f" Host: {config.cloud_host}")
console.print(" [dim]All CLI commands work against cloud[/dim]")
else:
console.print(" Mode: [yellow]Local (disabled)[/yellow]")
console.print(" [dim]All CLI commands work locally[/dim]")
console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
console.print("[bold blue]Cloud Status[/bold blue]")
console.print(f" Host: {config.cloud_host}")
console.print(
f" API Key: {'[green]configured[/green]' if config.cloud_api_key else '[yellow]not set[/yellow]'}"
)
oauth_status = "[yellow]not logged in[/yellow]"
if tokens:
if auth.is_token_valid(tokens):
oauth_status = "[green]token valid[/green]"
else:
oauth_status = "[yellow]token expired[/yellow]"
console.print(f" OAuth: {oauth_status}")
has_credentials = bool(config.cloud_api_key) or tokens is not None
if not has_credentials:
console.print(
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud api-key save <key>[/dim]"
)
return
# Get cloud configuration
# Quick connection check — just verify we can reach the cloud
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
# Prepare headers
headers = {}
try:
console.print("\n[blue]Checking cloud instance health...[/blue]")
# Make API request to check health
response = run_with_cleanup(
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health"))
console.print("\n[green]Cloud connected[/green]")
except CloudAPIError:
console.print("\n[yellow]Cloud not connected[/yellow]")
console.print(
"[dim]Try re-authenticating with 'bm cloud login' or 'bm cloud api-key save'.[/dim]"
)
health_data = response.json()
console.print("[green]Cloud instance is healthy[/green]")
# Display status details
if "status" in health_data:
console.print(f" Status: {health_data['status']}")
if "version" in health_data:
console.print(f" Version: {health_data['version']}")
if "timestamp" in health_data:
console.print(f" Timestamp: {health_data['timestamp']}")
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
except CloudAPIError as e:
console.print(f"[red]Error checking cloud health: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
except Exception:
console.print("\n[yellow]Cloud not connected[/yellow]")
@cloud_app.command("setup")
@@ -140,7 +133,7 @@ def setup() -> None:
"""Set up cloud sync by installing rclone and configuring credentials.
After setup, use project commands for syncing:
bm project add <name> <path> --local-path ~/projects/<name>
bm project add <name> --cloud --local-path ~/projects/<name>
bm project bisync --name <name> --resync # First time
bm project bisync --name <name> # Subsequent syncs
"""
@@ -172,7 +165,7 @@ def setup() -> None:
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Add a project with local sync path:")
console.print(" bm project add research --local-path ~/Documents/research")
console.print(" bm project add research --cloud --local-path ~/Documents/research")
console.print("\n Or configure sync for an existing project:")
console.print(" bm project sync-setup research ~/Documents/research")
console.print("\n2. Preview the initial sync (recommended):")
@@ -191,3 +184,98 @@ def setup() -> None:
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("promo")
def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disable CLI promos.")):
"""Enable or disable CLI cloud promo messages."""
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_promo_opt_out = not enabled
config_manager.save_config(config)
if enabled:
console.print("[green]Cloud promo messages enabled[/green]")
else:
track(EVENT_PROMO_OPTED_OUT)
console.print("[yellow]Cloud promo messages disabled[/yellow]")
# --- API key management subcommand group ---
api_key_app = typer.Typer(help="Manage cloud API keys")
cloud_app.add_typer(api_key_app, name="api-key")
@api_key_app.command("save")
def api_key_save(
api_key: str = typer.Argument(..., help="API key (bmc_ prefixed) for cloud access"),
) -> None:
"""Save an existing API key to local config.
Use when you already have an API key (e.g., from the web app).
Example:
bm cloud api-key save bmc_abc123...
"""
if not api_key.startswith("bmc_"):
console.print("[red]Error: API key must start with 'bmc_'[/red]")
raise typer.Exit(1)
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_api_key = api_key
config_manager.save_config(config)
console.print("[green]API key saved[/green]")
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
@api_key_app.command("create")
def api_key_create(
name: str = typer.Argument(..., help="Human-readable name for the API key"),
) -> None:
"""Create a new API key via the cloud API and save it locally.
Requires active OAuth session (run 'bm cloud login' first).
Example:
bm cloud api-key create "my-laptop"
"""
async def _create_key():
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
console.print(f"[dim]Creating API key '{name}'...[/dim]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/keys",
json_data={"name": name},
)
key_data = response.json()
api_key = key_data.get("key")
if not api_key:
console.print("[red]Error: No key returned from API[/red]")
raise typer.Exit(1)
# Save to config
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_api_key = api_key
config_manager.save_config(config)
console.print(f"[green]API key '{name}' created and saved[/green]")
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
try:
run_with_cleanup(_create_key())
except CloudAPIError as e:
console.print(f"[red]Error creating API key: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
@@ -0,0 +1,372 @@
"""Cloud sync commands for Basic Memory projects.
Commands for syncing, bisyncing, and checking integrity between local and cloud
project instances. These were previously in project.py but belong here since
they are cloud-specific operations.
"""
import os
from datetime import datetime
import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
from basic_memory.cli.commands.cloud.rclone_commands import (
RcloneError,
SyncProject,
get_project_bisync_state,
project_bisync,
project_check,
project_sync,
)
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 ConfigManager, ProjectEntry
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import generate_permalink, normalize_project_path
console = Console()
# --- Shared helpers ---
def _has_cloud_credentials(config) -> bool:
"""Return whether cloud credentials are available (API key or OAuth token)."""
from basic_memory.config import has_cloud_credentials
return has_cloud_credentials(config)
def _require_cloud_credentials(config) -> None:
"""Exit with actionable guidance when cloud credentials are missing."""
if _has_cloud_credentials(config):
return
console.print("[red]Error: cloud credentials are required for this command[/red]")
console.print("[dim]Run 'bm cloud login' or 'bm cloud api-key save <key>' first[/dim]")
raise typer.Exit(1)
async def _get_cloud_project(name: str) -> ProjectItem | None:
"""Fetch a project by name from the cloud API."""
async with get_client() as client:
projects_list = await ProjectClient(client).list_projects()
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
def _get_sync_project(
name: str, config, project_data: ProjectItem
) -> tuple[SyncProject, str | None]:
"""Build a SyncProject and resolve local_sync_path from config.
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
"""
sync_entry = config.projects.get(name)
# Support both new (path) and legacy (local_sync_path) configs
local_sync_path = (sync_entry.local_sync_path or sync_entry.path) if sync_entry else None
if not local_sync_path or not os.path.isabs(local_sync_path):
console.print(f"[red]Error: Project '{name}' has no local sync path configured[/red]")
console.print(f"\nConfigure sync with: bm cloud sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
return sync_project, local_sync_path
# --- Commands ---
@cloud_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""One-way sync: local -> cloud (make cloud identical to local).
Example:
bm cloud sync --name research
bm cloud sync --name research --dry-run
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
with force_routing(cloud=True):
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, local_sync_path = _get_sync_project(name, config, project_data)
# Run sync
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
if success:
console.print(f"[green]{name} synced successfully[/green]")
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
)
try:
with force_routing(cloud=True):
result = run_with_cleanup(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} sync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Sync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("bisync")
def bisync_project_command(
name: str = typer.Option(..., "--name", help="Project name to bisync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Two-way sync: local <-> cloud (bidirectional sync).
Examples:
bm cloud bisync --name research --resync # First time
bm cloud bisync --name research # Subsequent syncs
bm cloud bisync --name research --dry-run # Preview changes
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
with force_routing(cloud=True):
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, local_sync_path = _get_sync_project(name, config, project_data)
# Run bisync
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
success = project_bisync(
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
)
if success:
console.print(f"[green]{name} bisync completed successfully[/green]")
# Update config — sync_entry is guaranteed non-None because
# _get_sync_project validated local_sync_path (which comes from sync_entry)
sync_entry = config.projects.get(name)
assert sync_entry is not None
sync_entry.last_sync = datetime.now()
sync_entry.bisync_initialized = True
ConfigManager().save_config(config)
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
)
try:
with force_routing(cloud=True):
result = run_with_cleanup(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} bisync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Bisync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("check")
def check_project_command(
name: str = typer.Option(..., "--name", 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.
Example:
bm cloud check --name research
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
with force_routing(cloud=True):
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, local_sync_path = _get_sync_project(name, config, project_data)
# Run check
console.print(f"[blue]Checking {name} integrity...[/blue]")
match = project_check(sync_project, bucket_name, one_way=one_way)
if match:
console.print(f"[green]{name} files match[/green]")
else:
console.print(f"[yellow]!{name} has differences[/yellow]")
except RcloneError as e:
console.print(f"[red]Check error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("bisync-reset")
def bisync_reset(
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
) -> None:
"""Clear bisync state for a project.
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.
"""
import shutil
try:
state_path = get_project_bisync_state(name)
if not state_path.exists():
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
return
# Remove the entire state directory
shutil.rmtree(state_path)
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
raise typer.Exit(1)
@cloud_app.command("sync-setup")
def setup_project_sync(
name: str = typer.Argument(..., help="Project name"),
local_path: str = typer.Argument(..., help="Local sync directory"),
) -> None:
"""Configure local sync for an existing cloud project.
Example:
bm cloud sync-setup research ~/Documents/research
"""
import os
from pathlib import Path
config_manager = ConfigManager()
config = config_manager.config
_require_cloud_credentials(config)
async def _verify_project_exists():
"""Verify the project exists on cloud by listing all projects."""
async with get_client() as client:
projects_list = await ProjectClient(client).list_projects()
project_names = [p.name for p in projects_list.projects]
if name not in project_names:
raise ValueError(f"Project '{name}' not found on cloud")
return True
try:
# Verify project exists on cloud
with force_routing(cloud=True):
run_with_cleanup(_verify_project_exists())
# Resolve and create local path
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
resolved_path.mkdir(parents=True, exist_ok=True)
# Update project entry with sync path — path is always the local directory
entry = config.projects.get(name)
if entry:
entry.path = resolved_path.as_posix()
entry.local_sync_path = resolved_path.as_posix()
entry.bisync_initialized = False
entry.last_sync = None
else:
config.projects[name] = ProjectEntry(
path=resolved_path.as_posix(),
local_sync_path=resolved_path.as_posix(),
)
config_manager.save_config(config)
# Create the project in the local DB so the MCP server can immediately use it
async def _create_local_project():
async with get_client() as client:
data = {"name": name, "path": resolved_path.as_posix(), "set_default": False}
return await ProjectClient(client).create_project(data)
with force_routing(local=True):
try:
run_with_cleanup(_create_local_project())
except Exception:
pass # Project may already exist locally; reconcile on next startup
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
raise typer.Exit(1)
+15 -5
View File
@@ -10,7 +10,6 @@ import httpx
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_put
# Archive file extensions that should be skipped during upload
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
@@ -24,7 +23,7 @@ async def upload_path(
dry_run: bool = False,
*,
client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
put_func=call_put,
put_func: Callable | None = None,
) -> bool:
"""
Upload a file or directory to cloud project via WebDAV.
@@ -117,9 +116,20 @@ async def upload_path(
# Upload via HTTP PUT to WebDAV endpoint with mtime header
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
response = await put_func(
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
)
if put_func is not None:
# Test injection path
response = await put_func(
client,
remote_path,
content=content,
headers={"X-OC-Mtime": str(mtime)},
)
else:
response = await client.put(
remote_path,
content=content,
headers={"X-OC-Mtime": str(mtime)},
)
response.raise_for_status()
# Format total size based on magnitude
@@ -13,6 +13,7 @@ from basic_memory.cli.commands.cloud.cloud_utils import (
sync_project,
)
from basic_memory.cli.commands.cloud.upload import upload_path
from basic_memory.mcp.async_client import get_cloud_control_plane_client
console = Console()
@@ -86,7 +87,7 @@ def upload(
console.print(
f"[red]Project '{project}' does not exist.[/red]\n"
f"[yellow]Options:[/yellow]\n"
f" 1. Create it first: bm project add {project}\n"
f" 1. Create it first: bm project add {project} --cloud\n"
f" 2. Use --create-project flag to create automatically"
)
raise typer.Exit(1)
@@ -100,7 +101,12 @@ def upload(
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
success = await upload_path(
path, project, verbose=verbose, use_gitignore=not no_gitignore, dry_run=dry_run
path,
project,
verbose=verbose,
use_gitignore=not no_gitignore,
dry_run=dry_run,
client_cm_factory=get_cloud_control_plane_client,
)
if not success:
console.print("[red]Upload failed[/red]")
@@ -0,0 +1,113 @@
"""Workspace commands for Basic Memory cloud workspaces."""
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
console = Console()
workspace_app = typer.Typer(help="Manage cloud workspaces")
@workspace_app.command("list")
def list_workspaces() -> None:
"""List cloud workspaces available to the current OAuth session."""
async def _list():
return await get_available_workspaces()
try:
workspaces = run_with_cleanup(_list())
except RuntimeError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1)
except Exception as exc: # pragma: no cover
console.print(f"[red]Error listing workspaces: {exc}[/red]")
raise typer.Exit(1)
if not workspaces:
console.print("[yellow]No accessible workspaces found.[/yellow]")
return
config = ConfigManager().config
default_ws = config.default_workspace
table = Table(title="Available Workspaces")
table.add_column("Name", style="cyan")
table.add_column("Type", style="blue")
table.add_column("Role", style="green")
table.add_column("Tenant ID", style="yellow")
table.add_column("Default", style="magenta")
for workspace in workspaces:
is_default = "[X]" if workspace.tenant_id == default_ws else ""
table.add_row(
workspace.name,
workspace.workspace_type,
workspace.role,
workspace.tenant_id,
is_default,
)
console.print(table)
@workspace_app.command("set-default")
def set_default_workspace(
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
) -> None:
"""Set the default cloud workspace.
The default workspace is used as fallback when no per-project workspace
is configured. Resolves the identifier against available workspaces.
Examples:
bm cloud workspace set-default Personal
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
"""
async def _list():
return await get_available_workspaces()
try:
workspaces = run_with_cleanup(_list())
except RuntimeError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1)
if not workspaces:
console.print("[yellow]No accessible workspaces found.[/yellow]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
if not matches:
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(
f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
selected = matches[0]
config_manager = ConfigManager()
config = config_manager.config
config.default_workspace = selected.tenant_id
config_manager.save_config(config)
console.print(
f"[green]Default workspace set to '{selected.name}' ({selected.tenant_id})[/green]"
)
+29 -19
View File
@@ -9,11 +9,10 @@ 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.tools.utils import call_post, call_get
from basic_memory.mcp.clients import ProjectClient
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import ProjectInfoResponse
console = Console()
@@ -55,19 +54,18 @@ async def run_sync(
run_in_background: If True, return immediately; if False, wait for completion
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
try:
async with get_client() as client:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
url = f"/v2/projects/{project_item.external_id}/sync"
params = []
if force_full:
params.append("force_full=true")
if not run_in_background:
params.append("run_in_background=false")
if params:
url += "?" + "&".join(params)
response = await call_post(client, url)
data = response.json()
project_client = ProjectClient(client)
data = await project_client.sync(
project_item.external_id,
force_full=force_full,
run_in_background=run_in_background,
)
# Background mode returns {"message": "..."}, foreground returns SyncReportResponse
if "message" in data:
console.print(f"[green]{data['message']}[/green]")
@@ -88,12 +86,24 @@ async def run_sync(
async def get_project_info(project: str):
"""Get project information via API endpoint."""
try:
async with get_client() as client:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
return ProjectInfoResponse.model_validate(response.json())
return await ProjectClient(client).get_info(project_item.external_id)
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
error_text = str(e)
if "internal proxy error" in error_text.lower() and "not found in configuration" in (
error_text.lower()
):
console.print(
"[red]Project info failed: cloud returned an internal configuration error for "
"this project.[/red]"
)
console.print(
"[yellow]This is a cloud backend issue for detailed info lookups. "
"Use `bm project list --cloud` for project metadata until the service is updated."
"[/yellow]"
)
else:
console.print(f"[red]Project info failed: {e}[/red]")
raise typer.Exit(1)
+126 -1
View File
@@ -5,12 +5,13 @@ from pathlib import Path
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
from basic_memory.config import ConfigManager, ProjectMode
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
@@ -103,3 +104,127 @@ def reset(
# ensures db.shutdown_db() is called even if _reindex_projects changes
run_with_cleanup(_reindex_projects(app_config))
console.print("[green]Reindex complete[/green]")
@app.command()
def reindex(
embeddings: bool = typer.Option(
False, "--embeddings", "-e", help="Rebuild vector embeddings (requires semantic search)"
),
search: bool = typer.Option(False, "--search", "-s", help="Rebuild full-text search index"),
project: str = typer.Option(
None, "--project", "-p", help="Reindex a specific project (default: all)"
),
): # pragma: no cover
"""Rebuild search indexes and/or vector embeddings without dropping the database.
By default rebuilds everything (search + embeddings if semantic is enabled).
Use --search or --embeddings to rebuild only one.
Examples:
bm reindex # Rebuild everything
bm reindex --embeddings # Only rebuild vector embeddings
bm reindex --search # Only rebuild FTS index
bm reindex -p claw # Reindex only the 'claw' project
"""
# If neither flag is set, do both
if not embeddings and not search:
embeddings = True
search = True
config_manager = ConfigManager()
app_config = config_manager.config
if embeddings and not app_config.semantic_search_enabled:
console.print(
"[yellow]Semantic search is not enabled.[/yellow] "
"Set [cyan]semantic_search_enabled: true[/cyan] in config to use embeddings."
)
embeddings = False
if not search:
raise typer.Exit(0)
run_with_cleanup(_reindex(app_config, search=search, embeddings=embeddings, project=project))
async def _reindex(app_config, search: bool, embeddings: bool, project: str | None):
"""Run reindex operations."""
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.services.search_service import SearchService
from basic_memory.services.file_service import FileService
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.entity_parser import EntityParser
try:
await reconcile_projects_with_config(app_config)
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM,
)
project_repository = ProjectRepository(session_maker)
projects = await project_repository.get_active_projects()
if project:
projects = [p for p in projects if p.name == project]
if not projects:
# Check if it's a cloud-only project — those can't be reindexed locally
project_mode = app_config.get_project_mode(project)
if project_mode == ProjectMode.CLOUD:
console.print(
f"[yellow]Project '{project}' is a cloud project.[/yellow]\n"
"Reindexing is a local operation — cloud projects are "
"indexed on the server."
)
else:
console.print(f"[red]Project '{project}' not found.[/red]")
raise typer.Exit(1)
for proj in projects:
console.print(f"\n[bold]Project: [cyan]{proj.name}[/cyan][/bold]")
if search:
console.print(" Rebuilding full-text search index...")
sync_service = await get_sync_service(proj)
sync_dir = Path(proj.path)
await sync_service.sync(sync_dir, project_name=proj.name)
console.print(" [green]✓[/green] Full-text search index rebuilt")
if embeddings:
console.print(" Building vector embeddings...")
entity_repository = EntityRepository(session_maker, project_id=proj.id)
search_repository = create_search_repository(
session_maker, project_id=proj.id, app_config=app_config
)
project_path = Path(proj.path)
entity_parser = EntityParser(project_path)
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
file_service = FileService(project_path, markdown_processor, app_config=app_config)
search_service = SearchService(search_repository, entity_repository, file_service)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
) as progress:
task = progress.add_task(" Embedding entities...", total=None)
def on_progress(entity_id, index, total):
progress.update(task, total=total, completed=index)
stats = await search_service.reindex_vectors(progress_callback=on_progress)
progress.update(task, completed=stats["total_entities"])
console.print(
f" [green]✓[/green] Embeddings complete: "
f"{stats['embedded']} entities embedded, "
f"{stats['skipped']} skipped, "
f"{stats['errors']} errors"
)
console.print("\n[green]Reindex complete![/green]")
finally:
await db.shutdown_db()
+8 -8
View File
@@ -19,7 +19,6 @@ 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.mcp.tools.utils import call_post
from basic_memory.schemas.base import Entity
from basic_memory.schemas.project_info import ProjectInfoRequest
from basic_memory.schemas.search import SearchQuery
@@ -62,7 +61,7 @@ async def run_doctor() -> None:
api_note = Entity(
title=api_note_title,
directory="doctor",
entity_type="note",
note_type="note",
content_type="text/markdown",
content=f"# {api_note_title}\n\n- [note] API to file check",
entity_metadata={"tags": ["doctor"]},
@@ -98,11 +97,10 @@ async def run_doctor() -> None:
await processor.write_file(manual_path, manual_markdown)
console.print("[green]OK[/green] Manual file written")
sync_response = await call_post(
client,
f"/v2/projects/{project_id}/sync?force_full=true&run_in_background=false",
sync_data = await project_client.sync(
project_id, force_full=True, run_in_background=False
)
sync_report = SyncReportResponse.model_validate(sync_response.json())
sync_report = SyncReportResponse.model_validate(sync_data)
if sync_report.total == 0:
raise ValueError("Sync did not detect any changes")
@@ -118,8 +116,7 @@ async def run_doctor() -> None:
console.print("[green]OK[/green] Search confirmed manual file")
status_response = await call_post(client, f"/v2/projects/{project_id}/status")
status_report = SyncReportResponse.model_validate(status_response.json())
status_report = await project_client.get_status(project_id)
if status_report.total != 0:
raise ValueError("Project status not clean after sync")
@@ -142,6 +139,9 @@ def doctor(
"""Run local consistency checks to verify file/database sync."""
try:
validate_routing_flags(local, cloud)
# Doctor runs local filesystem checks — always default to local routing
if not local and not cloud:
local = True
with force_routing(local=local, cloud=cloud):
run_with_cleanup(run_doctor())
except (ToolError, ValueError) as e:
+4 -4
View File
@@ -183,10 +183,10 @@ def format(
By default, formats all .md, .json, and .canvas files in the current project.
Examples:
basic-memory format # Format all files in current project
basic-memory format --project research # Format files in specific project
basic-memory format notes/meeting.md # Format a specific file
basic-memory format notes/ # Format all files in directory
bm format # Format all files in current project
bm format --project research # Format files in specific project
bm format notes/meeting.md # Format a specific file
bm format notes/ # Format all files in directory
"""
try:
run_with_cleanup(run_format(path, project))
@@ -44,7 +44,7 @@ def import_chatgpt(
2. Convert them to linear markdown conversations
3. Save as clean, readable markdown files
After importing, run 'basic-memory sync' to index the new files.
After importing, run 'bm reindex --search' to index the new files.
"""
try:
@@ -60,7 +60,9 @@ def import_chatgpt(
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
# Create importer and run import
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
importer = ChatGPTImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
with conversations_json.open("r", encoding="utf-8") as file:
json_data = json.load(file)
result = run_with_cleanup(importer.import_data(json_data, folder))
@@ -79,7 +81,7 @@ def import_chatgpt(
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'bm reindex --search' to index the new files.")
except Exception as e:
logger.error("Import failed")
@@ -44,7 +44,7 @@ def import_claude(
2. Create markdown files for each conversation
3. Format content in clean, readable markdown
After importing, run 'basic-memory sync' to index the new files.
After importing, run 'bm reindex --search' to index the new files.
"""
config = get_project_config()
@@ -57,7 +57,9 @@ def import_claude(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
importer = ClaudeConversationsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home / folder
@@ -82,7 +84,7 @@ def import_claude(
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'bm reindex --search' to index the new files.")
except Exception as e:
logger.error("Import failed")
@@ -44,7 +44,7 @@ def import_projects(
2. Store docs in a docs/ subdirectory
3. Place prompt template in project root
After importing, run 'basic-memory sync' to index the new files.
After importing, run 'bm reindex --search' to index the new files.
"""
config = get_project_config()
try:
@@ -56,7 +56,9 @@ def import_projects(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
importer = ClaudeProjectsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home / base_folder if base_folder else config.home
@@ -81,7 +83,7 @@ def import_projects(
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'bm reindex --search' to index the new files.")
except Exception as e:
logger.error("Import failed")
@@ -55,7 +55,9 @@ def memory_json(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
importer = MemoryJsonImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home if not destination_folder else config.home / destination_folder
+31 -13
View File
@@ -1,21 +1,24 @@
"""MCP server command with streamable HTTP transport."""
import os
from typing import Any, Optional
import typer
from typing import Optional
from loguru import logger
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager, init_mcp_logging
# Import mcp instance (has lifespan that handles initialization and file sync)
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
# Import mcp tools to register them
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
class _DeferredMcpServer:
def run(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
from basic_memory.mcp.server import mcp as live_mcp_server
# Import prompts to register them
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
from loguru import logger
live_mcp_server.run(*args, **kwargs)
# Keep module-level attribute for tests/monkeypatching while deferring heavy import.
mcp_server = _DeferredMcpServer()
@app.command()
@@ -33,7 +36,7 @@ def mcp(
This command starts an MCP server using one of three transport options:
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- streamable-http: Recommended for web deployments
- sse: Server-Sent Events (for compatibility with existing clients)
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
@@ -42,10 +45,25 @@ def mcp(
Users who have cloud mode enabled can still use local MCP for Claude Code
and Claude Desktop while using cloud MCP for web and mobile access.
"""
# Force local routing for local MCP server
# Why: The local MCP server should always talk to the local API, not the cloud proxy.
# Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access.
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
# --- Routing setup ---
# Trigger: MCP server command invocation.
# Why: HTTP/SSE transports serve as local API endpoints and must never
# route through cloud. Stdio is a client-facing protocol that
# should honor per-project routing (local or cloud).
# Outcome: HTTP/SSE get explicit local override; stdio passes through
# whatever env vars are already set (honoring external overrides)
# and defaults to per-project routing resolution.
if transport in ("streamable-http", "sse"):
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
# stdio: no env var manipulation — per-project routing applies by default,
# and externally-set env vars (e.g. BASIC_MEMORY_FORCE_CLOUD) are honored.
# Import mcp tools/prompts to register them with the server
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
File diff suppressed because it is too large Load Diff
+29 -11
View File
@@ -1,14 +1,13 @@
"""CLI routing utilities for --local/--cloud flag handling.
This module provides utilities for CLI commands to override the default routing
behavior (determined by cloud_mode_enabled in config). This allows users to:
1. Use local MCP server even when cloud mode is enabled
2. Force local routing for specific CLI commands with --local flag
3. Force cloud routing with --cloud flag (requires authentication)
This module provides utilities for CLI commands to override default routing.
This allows users to force local or cloud routing per-command.
The routing is controlled via environment variables:
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
- BASIC_MEMORY_FORCE_CLOUD: When "true", forces cloud proxy transport
- BASIC_MEMORY_EXPLICIT_ROUTING: When "true", signals that --local/--cloud
was explicitly passed, overriding per-project routing in get_client()
- These are checked in basic_memory.mcp.async_client.get_client()
"""
@@ -24,9 +23,14 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
Sets environment variables that are checked by get_client() to determine
whether to use local ASGI transport or cloud proxy transport.
When either flag is set, BASIC_MEMORY_EXPLICIT_ROUTING is also set so
that get_client() skips per-project routing and honors the flag directly.
This only affects CLI commands — the MCP server sets FORCE_LOCAL directly
(without EXPLICIT_ROUTING), so per-project routing still works for MCP tools.
Args:
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
cloud: If True, clear force_local to allow cloud routing
local: If True, force local ASGI transport
cloud: If True, force cloud proxy transport
Usage:
with force_routing(local=True):
@@ -41,23 +45,37 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N
# Save original values
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
original_force_cloud = os.environ.get("BASIC_MEMORY_FORCE_CLOUD")
original_explicit = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
try:
if local:
# Force local routing by setting the env var
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
elif cloud:
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
os.environ["BASIC_MEMORY_FORCE_CLOUD"] = "true"
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
# If neither is set, don't change anything (use default behavior)
yield
finally:
# Restore original value
# Restore original values
if original_force_local is None:
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
else:
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
if original_force_cloud is None:
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
else:
os.environ["BASIC_MEMORY_FORCE_CLOUD"] = original_force_cloud
if original_explicit is None:
os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None)
else:
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = original_explicit
def validate_routing_flags(local: bool, cloud: bool) -> None:
"""Validate that --local and --cloud flags are not both specified.
+391
View File
@@ -0,0 +1,391 @@
"""Schema management CLI commands for Basic Memory.
Provides CLI access to schema validation, inference, and drift detection.
Registered as a subcommand group: `bm schema validate`, `bm schema infer`, `bm schema diff`.
Each command calls the corresponding MCP tool with output_format="json" and
renders the result as Rich tables — same code path as `bm tool schema-*` but
with human-friendly formatting.
"""
import json
from typing import Annotated, Optional
import typer
from loguru import logger
from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
console = Console()
schema_app = typer.Typer(help="Schema management commands")
app.add_typer(schema_app, name="schema")
def _resolve_project_name(project: Optional[str]) -> Optional[str]:
"""Resolve project name from CLI argument or config default."""
config_manager = ConfigManager()
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
return project_name
return config_manager.default_project
# --- Rendering helpers ---
def _render_validate_table(data: dict) -> None:
"""Render a validation report dict as a Rich table."""
note_type = data.get("note_type")
title_label = note_type or "all"
table = Table(title=f"Schema Validation: {title_label}")
table.add_column("Note", style="cyan")
table.add_column("Status", justify="center")
table.add_column("Warnings", justify="right")
table.add_column("Errors", justify="right")
for result in data.get("results", []):
warnings = result.get("warnings", [])
errors = result.get("errors", [])
passed = result.get("passed", True)
if passed and not warnings:
status = "[green]pass[/green]"
elif passed:
status = "[yellow]warn[/yellow]"
else:
status = "[red]fail[/red]"
table.add_row(
result.get("note_identifier", ""),
status,
str(len(warnings)),
str(len(errors)),
)
console.print(table)
console.print(
f"\nSummary: {data.get('valid_count', 0)}/{data.get('total_notes', 0)} valid, "
f"{data.get('warning_count', 0)} warnings, {data.get('error_count', 0)} errors"
)
def _render_infer_table(data: dict) -> None:
"""Render an inference report dict as a Rich table."""
note_type = data.get("note_type", "")
notes_analyzed = data.get("notes_analyzed", 0)
suggested_required = data.get("suggested_required", [])
suggested_optional = data.get("suggested_optional", [])
console.print(f"\n[bold]Analyzing {notes_analyzed} notes with type: {note_type}...[/bold]\n")
table = Table(title="Field Frequencies")
table.add_column("Field", style="cyan")
table.add_column("Source")
table.add_column("Count", justify="right")
table.add_column("Percentage", justify="right")
table.add_column("Suggested")
for freq in data.get("field_frequencies", []):
pct = f"{freq.get('percentage', 0):.0%}"
name = freq.get("name", "")
if name in suggested_required:
suggested = "[green]required[/green]"
elif name in suggested_optional:
suggested = "[yellow]optional[/yellow]"
else:
suggested = "[dim]excluded[/dim]"
table.add_row(
name,
freq.get("source", ""),
str(freq.get("count", 0)),
pct,
suggested,
)
console.print(table)
suggested_schema = data.get("suggested_schema", {})
if suggested_schema:
console.print("\n[bold]Suggested schema:[/bold]")
console.print(json.dumps(suggested_schema, indent=2))
def _render_diff_output(data: dict) -> None:
"""Render a drift report dict as Rich output."""
note_type = data.get("note_type", "")
new_fields = data.get("new_fields", [])
dropped_fields = data.get("dropped_fields", [])
cardinality_changes = data.get("cardinality_changes", [])
has_drift = new_fields or dropped_fields or cardinality_changes
if not has_drift:
console.print(f"[green]No drift detected for {note_type} schema.[/green]")
return
console.print(f"\n[bold]Schema drift detected for {note_type}:[/bold]\n")
if new_fields:
console.print("[green]+ New fields (common in notes, not in schema):[/green]")
for f in new_fields:
console.print(
f" + {f['name']}: {f.get('percentage', 0):.0%} of notes ({f.get('source', '')})"
)
if dropped_fields:
console.print("[red]- Dropped fields (in schema, rare in notes):[/red]")
for f in dropped_fields:
console.print(
f" - {f['name']}: {f.get('percentage', 0):.0%} of notes ({f.get('source', '')})"
)
if cardinality_changes:
console.print("[yellow]~ Cardinality changes:[/yellow]")
for change in cardinality_changes:
console.print(f" ~ {change}")
# --- Commands ---
@schema_app.command()
def validate(
target: Annotated[
Optional[str],
typer.Argument(help="Note path or note type to validate"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
strict: bool = typer.Option(False, "--strict", help="Exit with error on validation failures"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Validate notes against their schemas.
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
(e.g., person). If omitted, validates all notes that have schemas.
Use --json for machine-readable output.
Use --strict to exit with error code 1 if any validation errors are found.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
# Heuristic: if target contains / or ., treat as identifier; otherwise as note type
note_type, identifier = None, None
if target:
if "/" in target or "." in target:
identifier = target
else:
note_type = target
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_schema_validate(
note_type=note_type,
identifier=identifier,
project=project_name,
output_format="json",
)
)
# Handle error responses
if isinstance(result, dict) and "error" in result:
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
console.print(f"[yellow]{result['error']}[/yellow]")
return
# output_format="json" guarantees a dict return
assert isinstance(result, dict)
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
_render_validate_table(result)
if strict and result.get("error_count", 0) > 0:
raise typer.Exit(1)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
if not isinstance(e, typer.Exit):
logger.error(f"Error during schema validate: {e}")
typer.echo(f"Error during schema validate: {e}", err=True)
raise typer.Exit(1)
raise
@schema_app.command()
def infer(
note_type: Annotated[
str,
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
],
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
threshold: float = typer.Option(
0.25, "--threshold", help="Minimum frequency for optional fields (0-1)"
),
save: bool = typer.Option(False, "--save", help="Save inferred schema to schema/ directory"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Infer schema from existing notes of a type.
Analyzes all notes with the given type and suggests a Picoschema
definition based on observation and relation frequency.
Fields present in 95%+ of notes become required. Fields above the
threshold (default 25%) become optional. Fields below threshold are excluded.
Use --json for machine-readable output.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_schema_infer(
note_type=note_type,
threshold=threshold,
project=project_name,
output_format="json",
)
)
# Handle error responses
if isinstance(result, dict) and "error" in result:
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
console.print(f"[yellow]{result['error']}[/yellow]")
return
# output_format="json" guarantees a dict return
assert isinstance(result, dict)
# Handle zero notes
if result.get("notes_analyzed", 0) == 0:
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
return
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
_render_infer_table(result)
if save:
console.print(
f"\n[yellow]--save not yet implemented. "
f"Copy the schema above into schema/{note_type}.md[/yellow]"
)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
if not isinstance(e, typer.Exit):
logger.error(f"Error during schema infer: {e}")
typer.echo(f"Error during schema infer: {e}", err=True)
raise typer.Exit(1)
raise
@schema_app.command()
def diff(
note_type: Annotated[
str,
typer.Argument(help="Note type to check for drift"),
],
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Show drift between schema and actual usage.
Compares the existing schema definition against how notes of that type
are actually structured. Identifies new fields,
dropped fields, and cardinality changes.
Use --json for machine-readable output.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_schema_diff(
note_type=note_type,
project=project_name,
output_format="json",
)
)
# Handle error responses
if isinstance(result, dict) and "error" in result:
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
console.print(f"[yellow]{result['error']}[/yellow]")
return
# output_format="json" guarantees a dict return
assert isinstance(result, dict)
if json_output:
print(json.dumps(result, indent=2, default=str))
else:
_render_diff_output(result)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
if not isinstance(e, typer.Exit):
logger.error(f"Error during schema diff: {e}")
typer.echo(f"Error during schema diff: {e}", err=True)
raise typer.Exit(1)
raise
+41 -17
View File
@@ -1,5 +1,6 @@
"""Status command for basic-memory CLI."""
import json
from typing import Set, Dict
from typing import Annotated, Optional
@@ -12,8 +13,9 @@ from rich.tree import Tree
from basic_memory.cli.app import app
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas import SyncReportResponse
from basic_memory.mcp.project_context import get_active_project
@@ -140,20 +142,20 @@ def display_changes(
console.print(Panel(tree, expand=False))
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
"""Check sync status of files vs database."""
async def run_status(
project: Optional[str] = None,
) -> tuple[str, SyncReportResponse]:
"""Fetch sync status of files vs database.
try:
async with get_client() as client:
project_item = await get_active_project(client, project, None)
response = await call_post(client, f"/v2/projects/{project_item.external_id}/status")
sync_report = SyncReportResponse.model_validate(response.json())
Returns (project_name, sync_report) for the caller to render.
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
sync_report = await ProjectClient(client).get_status(project_item.external_id)
return project_item.name, sync_report
@app.command()
@@ -163,6 +165,7 @@ def status(
typer.Option(help="The project name."),
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -170,6 +173,7 @@ def status(
):
"""Show sync status between files and database.
Use --json for machine-readable output.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
@@ -177,12 +181,32 @@ def status(
try:
validate_routing_flags(local, cloud)
# Trigger: no explicit routing flag provided
# Why: status scans the local filesystem — cloud routing would use the
# Docker-internal path stored in the cloud database, which doesn't
# exist locally.
# Outcome: default to local routing unless --cloud was explicitly requested.
if not local and not cloud:
local = True
with force_routing(local=local, cloud=cloud):
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
project_name, sync_report = run_with_cleanup(run_status(project))
if json_output:
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
else:
display_changes(project_name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except typer.Exit:
raise
except Exception as e:
logger.error(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
typer.echo(f"Error checking status: {e}", err=True)
raise typer.Exit(code=1) # pragma: no cover
File diff suppressed because it is too large Load Diff
-1
View File
@@ -35,7 +35,6 @@ class CliContainer:
"""
config = ConfigManager().config
mode = resolve_runtime_mode(
cloud_mode_enabled=config.cloud_mode_enabled,
is_test_env=config.is_test_env,
)
return cls(config=config, mode=mode)
+26 -17
View File
@@ -1,25 +1,34 @@
"""Main CLI entry point for basic-memory.""" # pragma: no cover
import sys
import warnings
from basic_memory.cli.app import app # pragma: no cover
# Register commands
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
cloud,
db,
doctor,
import_chatgpt,
import_claude_conversations,
import_claude_projects,
import_memory_json,
mcp,
project,
status,
tool,
)
# Re-apply warning filter AFTER all imports
# (authlib adds a DeprecationWarning filter that overrides ours)
import warnings # pragma: no cover
def _version_only_invocation(argv: list[str]) -> bool:
# Trigger: invocation is exactly `bm --version` or `bm -v`
# Why: avoid importing command modules on the hot version path
# Outcome: eager version callback exits quickly with minimal startup work
return len(argv) == 1 and argv[0] in {"--version", "-v"}
if not _version_only_invocation(sys.argv[1:]):
# Register commands only when not short-circuiting for --version
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
cloud,
db,
doctor,
import_chatgpt,
import_claude_conversations,
import_claude_projects,
import_memory_json,
mcp,
project,
schema,
status,
tool,
)
warnings.filterwarnings("ignore") # pragma: no cover
+130
View File
@@ -0,0 +1,130 @@
"""Cloud promo messaging for CLI entrypoint."""
import os
import sys
from rich.console import Console
from rich.panel import Panel
import basic_memory
from basic_memory.cli.analytics import track, EVENT_PROMO_SHOWN
from basic_memory.config import ConfigManager
OSS_DISCOUNT_CODE = "BMFOSS"
CLOUD_LEARN_MORE_URL = (
"https://basicmemory.com?utm_source=bm-foss&utm_medium=promo&utm_campaign=cloud-upsell"
)
def _promos_disabled_by_env() -> bool:
"""Check environment-level kill switch for promo output."""
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
return value in {"1", "true", "yes"}
def _is_interactive_session() -> bool:
"""Return whether stdin/stdout are interactive terminals."""
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except ValueError:
# Trigger: stdin/stdout already closed (e.g., MCP stdio transport shutdown)
# Why: isatty() raises ValueError on closed file descriptors
# Outcome: treat as non-interactive, suppressing promo output
return False
def _build_cloud_promo_message() -> str:
"""Build benefit-led cloud upsell copy with Rich markup."""
return (
"☁️ [bold]Your knowledge, everywhere.[/bold] ✨\n"
"Stop losing context when you switch machines.\n"
"Basic Memory Cloud syncs your memory across every device, including mobile and web.\n"
"Try it free for 7 days.\n"
f"Use [bold cyan]{OSS_DISCOUNT_CODE}[/bold cyan] for 20% off when you subscribe.\n"
"[bold green]→ bm cloud login[/bold green]"
)
def maybe_show_init_line(
invoked_subcommand: str | None,
*,
config_manager: ConfigManager | None = None,
is_interactive: bool | None = None,
console: Console | None = None,
) -> None:
"""Show a one-time init confirmation line before command output."""
manager = config_manager or ConfigManager()
config = manager.load_config()
interactive = _is_interactive_session() if is_interactive is None else is_interactive
# Same gates as the cloud promo — suppress in non-interactive, env kill-switch,
# mcp/root-help contexts, or when already shown.
if _promos_disabled_by_env() or not interactive:
return
if invoked_subcommand in {None, "mcp"}:
return
if config.cloud_promo_first_run_shown:
return
out = console or Console()
out.print("Basic Memory initialized ✓")
def maybe_show_cloud_promo(
invoked_subcommand: str | None,
*,
config_manager: ConfigManager | None = None,
is_interactive: bool | None = None,
console: Console | None = None,
) -> None:
"""Show cloud promo copy when discovery gates are satisfied."""
manager = config_manager or ConfigManager()
config = manager.load_config()
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
has_cloud_access = bool(config.cloud_api_key) or auth.load_tokens() is not None
interactive = _is_interactive_session() if is_interactive is None else is_interactive
# Trigger: environment-level promo suppression or non-interactive execution.
# Why: avoid polluting scripts/CI output and support a hard opt-out.
# Outcome: skip all promo copy for this invocation.
if _promos_disabled_by_env() or not interactive:
return
# Trigger: command context where cloud promo is not actionable.
# Why: mcp/stdin protocol and root help flows should stay noise-free.
# Outcome: command continues without promo messaging.
if invoked_subcommand in {None, "mcp"}:
return
if has_cloud_access or config.cloud_promo_opt_out:
return
show_first_run = not config.cloud_promo_first_run_shown
show_version_notice = config.cloud_promo_last_version_shown != basic_memory.__version__
if not show_first_run and not show_version_notice:
return
out = console or Console()
out.print(
Panel(
_build_cloud_promo_message(),
title="Basic Memory Cloud",
border_style="cyan",
expand=False,
)
)
out.print(f"Learn more at [link={CLOUD_LEARN_MORE_URL}]{CLOUD_LEARN_MORE_URL}[/link]")
out.print("[dim]Disable with: bm cloud promo --off[/dim]")
trigger = "first_run" if show_first_run else "version_bump"
track(EVENT_PROMO_SHOWN, {"trigger": trigger})
config.cloud_promo_first_run_shown = True
config.cloud_promo_last_version_shown = basic_memory.__version__
manager.save_config(config)
+380 -60
View File
@@ -1,7 +1,9 @@
"""Configuration management for basic-memory."""
import importlib.util
import json
import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -9,7 +11,7 @@ from typing import Any, Dict, Literal, Optional, List, Tuple
from enum import Enum
from loguru import logger
from pydantic import BaseModel, Field, model_validator
from pydantic import AliasChoices, BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from basic_memory.utils import setup_logging, generate_permalink
@@ -24,6 +26,13 @@ WATCH_STATUS_JSON = "watch-status.json"
Environment = Literal["test", "dev", "user"]
class ProjectMode(str, Enum):
"""Per-project routing mode."""
LOCAL = "local"
CLOUD = "cloud"
class DatabaseBackend(str, Enum):
"""Supported database backends."""
@@ -31,12 +40,21 @@ class DatabaseBackend(str, Enum):
POSTGRES = "postgres"
def _default_semantic_search_enabled() -> bool:
"""Enable semantic search by default when required local semantic dependencies exist."""
required_modules = ("fastembed", "sqlite_vec")
return all(
importlib.util.find_spec(module_name) is not None for module_name in required_modules
)
@dataclass
class ProjectConfig:
"""Configuration for a specific basic-memory project."""
name: str
home: Path
mode: ProjectMode = ProjectMode.LOCAL
@property
def project(self):
@@ -52,6 +70,9 @@ class CloudProjectConfig(BaseModel):
This tracks the local working directory and sync state for a project
that is synced with Basic Memory Cloud.
DEPRECATED: Kept for backward-compatible migration only. New code should
use ProjectEntry fields (cloud_sync_path, bisync_initialized, last_sync).
"""
local_path: str = Field(description="Local working directory path for this cloud project")
@@ -63,26 +84,57 @@ class CloudProjectConfig(BaseModel):
)
class ProjectEntry(BaseModel):
"""Unified project configuration entry.
Replaces the old triple of projects (Dict[str, str]), project_modes
(Dict[str, ProjectMode]), and cloud_projects (Dict[str, CloudProjectConfig])
with a single structure per project.
"""
path: str = Field(description="Local filesystem path for the project")
mode: ProjectMode = Field(
default=ProjectMode.LOCAL,
description="Routing mode: local (in-process ASGI) or cloud (remote API)",
)
workspace_id: Optional[str] = Field(
default=None,
description="Cloud workspace tenant_id. Set by 'bm project set-cloud --workspace'.",
)
# Cloud sync state (replaces CloudProjectConfig)
local_sync_path: Optional[str] = Field(
default=None,
description="Local working directory for bisync",
validation_alias=AliasChoices("local_sync_path", "cloud_sync_path"),
)
bisync_initialized: bool = Field(
default=False,
description="Whether rclone bisync baseline has been established",
)
last_sync: Optional[datetime] = Field(
default=None,
description="Timestamp of last successful sync operation",
)
class BasicMemoryConfig(BaseSettings):
"""Pydantic model for Basic Memory global configuration."""
env: Environment = Field(default="dev", description="Environment name")
projects: Dict[str, str] = Field(
projects: Dict[str, ProjectEntry] = Field(
default_factory=lambda: {
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
"main": ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
}
if os.getenv("BASIC_MEMORY_HOME")
else {},
description="Mapping of project names to their filesystem paths",
description="Mapping of project names to their ProjectEntry configuration",
)
default_project: str = Field(
default="main",
description="Name of the default project to use",
)
default_project_mode: bool = Field(
default=False,
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
default_project: Optional[str] = Field(
default=None,
description="Name of the default project to use. When set, acts as fallback when no project parameter is specified. Set to null to disable automatic project resolution.",
)
# overridden by ~/.basic-memory/config.json
@@ -99,6 +151,59 @@ class BasicMemoryConfig(BaseSettings):
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
)
# Semantic search configuration
semantic_search_enabled: bool = Field(
default_factory=_default_semantic_search_enabled,
description="Enable semantic search (vector/hybrid retrieval). Works on both SQLite and Postgres backends. Requires semantic dependencies (included by default).",
)
semantic_embedding_provider: str = Field(
default="fastembed",
description="Embedding provider for local semantic indexing/search.",
)
semantic_embedding_model: str = Field(
default="bge-small-en-v1.5",
description="Embedding model identifier used by the local provider.",
)
semantic_embedding_dimensions: int | None = Field(
default=None,
description="Embedding vector dimensions. Auto-detected from provider if not set (384 for FastEmbed, 1536 for OpenAI).",
)
semantic_embedding_batch_size: int = Field(
default=64,
description="Batch size for embedding generation.",
gt=0,
)
semantic_embedding_sync_batch_size: int = Field(
default=64,
description="Batch size for vector sync orchestration flushes.",
gt=0,
)
semantic_embedding_cache_dir: str | None = Field(
default=None,
description="Optional cache directory for FastEmbed model artifacts.",
)
semantic_embedding_threads: int | None = Field(
default=None,
description="Optional FastEmbed runtime thread count override.",
gt=0,
)
semantic_embedding_parallel: int | None = Field(
default=None,
description="Optional FastEmbed embed() parallelism override.",
gt=0,
)
semantic_vector_k: int = Field(
default=100,
description="Vector candidate count for vector and hybrid retrieval.",
gt=0,
)
semantic_min_similarity: float = Field(
default=0.55,
description="Minimum similarity score for vector search results. Results below this threshold are filtered out. 0.0 disables filtering.",
ge=0.0,
le=1.0,
)
# Database connection pool configuration (Postgres only)
db_pool_size: int = Field(
default=20,
@@ -160,6 +265,26 @@ class BasicMemoryConfig(BaseSettings):
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
)
write_note_overwrite_default: bool = Field(
default=False,
description=(
"Default value for write_note's overwrite parameter. "
"When False (default), write_note errors if note already exists. "
"Set to True to restore pre-v0.20 upsert behavior. "
"Env: BASIC_MEMORY_WRITE_NOTE_OVERWRITE_DEFAULT"
),
)
ensure_frontmatter_on_sync: bool = Field(
default=True,
description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.",
)
permalinks_include_project: bool = Field(
default=True,
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",
)
skip_initialization_sync: bool = Field(
default=False,
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
@@ -211,16 +336,117 @@ class BasicMemoryConfig(BaseSettings):
description="Basic Memory Cloud host URL",
)
cloud_mode: bool = Field(
cloud_promo_opt_out: bool = Field(
default=False,
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
description="Disable CLI cloud promo messages when true.",
)
cloud_projects: Dict[str, CloudProjectConfig] = Field(
default_factory=dict,
description="Cloud project sync configuration mapping project names to their local paths and sync state",
cloud_promo_first_run_shown: bool = Field(
default=False,
description="Tracks whether the first-run cloud promo message has been shown.",
)
cloud_promo_last_version_shown: Optional[str] = Field(
default=None,
description="Most recent cloud promo version shown in CLI.",
)
cloud_api_key: Optional[str] = Field(
default=None,
description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.",
)
default_workspace: Optional[str] = Field(
default=None,
description="Default cloud workspace tenant_id. Set by 'bm cloud workspace set-default'.",
)
@model_validator(mode="before")
@classmethod
def migrate_legacy_projects(cls, data: Any) -> Any:
"""Migrate old-format config (Dict[str, str]) to new ProjectEntry format.
Old format stored projects as three separate dicts:
projects: {"name": "/path"}
project_modes: {"name": "cloud"}
cloud_projects: {"name": {"local_path": "...", ...}}
New format unifies them into:
projects: {"name": {"path": "/path", "mode": "cloud", ...}}
Also removes stale keys (default_project_mode, permalinks_include_project)
that are no longer part of the config model.
"""
if not isinstance(data, dict):
return data
# --- Remove stale keys from old config versions ---
data.pop("default_project_mode", None)
data.pop("cloud_mode", None)
projects = data.get("projects", {})
if not projects:
return data
# Check if already in new format — peek at first value
first_value = next(iter(projects.values()), None)
if isinstance(first_value, str):
# Old format: {"name": "/path"} → convert
project_modes = data.pop("project_modes", {})
cloud_projects = data.pop("cloud_projects", {})
new_projects: Dict[str, Any] = {}
for name, path in projects.items():
entry: Dict[str, Any] = {"path": path}
if name in project_modes:
entry["mode"] = project_modes[name]
if name in cloud_projects:
cp = cloud_projects[name]
if isinstance(cp, dict):
entry["local_sync_path"] = cp.get("local_path")
entry["bisync_initialized"] = cp.get("bisync_initialized", False)
entry["last_sync"] = cp.get("last_sync")
else:
# Already a CloudProjectConfig-like object
entry["local_sync_path"] = getattr(cp, "local_path", None)
entry["bisync_initialized"] = getattr(cp, "bisync_initialized", False)
entry["last_sync"] = getattr(cp, "last_sync", None)
new_projects[name] = entry
# Pick up cloud_projects entries not already in projects
# These are cloud-only projects — path should be the local working
# directory (if one exists), local_path goes into local_sync_path for bisync
for name, cp in cloud_projects.items():
if name not in new_projects:
if isinstance(cp, dict):
local_path = cp.get("local_path", "")
new_projects[name] = {
"path": local_path or "",
"mode": project_modes.get(name, "cloud"),
"local_sync_path": local_path,
"bisync_initialized": cp.get("bisync_initialized", False),
"last_sync": cp.get("last_sync"),
}
data["projects"] = new_projects
else:
# New format or dict-based — just clean up stale keys
data.pop("project_modes", None)
data.pop("cloud_projects", None)
# --- Promote local_sync_path into path for cloud projects with slug paths ---
# Trigger: project entry has local_sync_path set but path is a cloud slug (not absolute)
# Why: path must always be the local filesystem path; the cloud remote is derivable
# Outcome: path becomes the local directory, local_sync_path kept for backwards compat
projects = data.get("projects", {})
for name, entry in projects.items():
if isinstance(entry, dict):
lsp = entry.get("local_sync_path")
path = entry.get("path", "")
if lsp and not os.path.isabs(path):
entry["path"] = lsp
return data
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -238,27 +464,33 @@ class BasicMemoryConfig(BaseSettings):
or os.getenv("PYTEST_CURRENT_TEST") is not None
)
@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
def get_project_mode(self, project_name: str) -> ProjectMode:
"""Get the routing mode for a project.
Priority:
1. BASIC_MEMORY_CLOUD_MODE environment variable
2. Config file value (cloud_mode)
Returns the per-project mode if set.
Unknown projects (not in local config) default to CLOUD —
local projects are always registered in config.
"""
env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
if env_value in ("true", "1", "yes"):
return True
elif env_value in ("false", "0", "no"):
return False
# Fall back to config file value
return self.cloud_mode
entry = self.projects.get(project_name)
return entry.mode if entry else ProjectMode.CLOUD
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
"""Set the routing mode for a project.
Creates a minimal ProjectEntry if the project doesn't already exist,
preserving backward compatibility with code that sets mode before
adding a full project entry.
"""
if project_name in self.projects:
self.projects[project_name].mode = mode
else:
self.projects[project_name] = ProjectEntry(path="", mode=mode)
@classmethod
def for_cloud_tenant(
cls,
database_url: str,
projects: Optional[Dict[str, str]] = None,
projects: Optional[Dict[str, "ProjectEntry"]] = None,
) -> "BasicMemoryConfig":
"""Create config for cloud tenant - no config.json, database is source of truth.
@@ -280,7 +512,6 @@ class BasicMemoryConfig(BaseSettings):
database_backend=DatabaseBackend.POSTGRES,
database_url=database_url,
projects=projects or {},
cloud_mode=True,
skip_initialization_sync=True,
)
@@ -296,7 +527,7 @@ class BasicMemoryConfig(BaseSettings):
if name not in self.projects:
raise ValueError(f"Project '{name}' not found in configuration")
return Path(self.projects[name])
return Path(self.projects[name].path)
def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization."""
@@ -304,15 +535,25 @@ class BasicMemoryConfig(BaseSettings):
if self.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
return # pragma: no cover
# Ensure at least one project exists; if none exist then create main
if not self.projects: # pragma: no cover
self.projects["main"] = str(
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
# Trigger: no projects configured (fresh install or empty config)
# Why: every config needs at least one project to be functional
# Outcome: creates "main" project using BASIC_MEMORY_HOME or ~/basic-memory
if not self.projects:
self.projects["main"] = ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
# Ensure default project is valid (i.e. points to an existing project)
if self.default_project not in self.projects: # pragma: no cover
# Set default to first available project
# Trigger: default_project was not explicitly provided in the input data
# (config file omitted the key, or BasicMemoryConfig() called with no args)
# Why: callers like get_project_config() expect a valid project name;
# but explicit None (discovery mode) must be preserved
# Outcome: sets default_project to the first available project
if "default_project" not in self.model_fields_set:
self.default_project = next(iter(self.projects.keys()))
# Trigger: default_project was explicitly set but references a non-existent project
# Why: project may have been removed or renamed since config was saved
# Outcome: corrects to the first available project
elif self.default_project is not None and self.default_project not in self.projects:
self.default_project = next(iter(self.projects.keys()))
@property
@@ -321,8 +562,11 @@ class BasicMemoryConfig(BaseSettings):
This is the single database that will store all knowledge data
across all projects.
Uses BASIC_MEMORY_CONFIG_DIR when set so each process/worktree can
isolate both config and database state.
"""
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
database_path = self.data_dir_path / APP_DATABASE_NAME
if not database_path.exists(): # pragma: no cover
database_path.parent.mkdir(parents=True, exist_ok=True)
database_path.touch()
@@ -344,7 +588,10 @@ class BasicMemoryConfig(BaseSettings):
@property
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
"""Get all configured projects as ProjectConfig objects."""
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
return [
ProjectConfig(name=name, home=Path(entry.path), mode=entry.mode)
for name, entry in self.projects.items()
]
@model_validator(mode="after")
def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
@@ -357,8 +604,11 @@ class BasicMemoryConfig(BaseSettings):
if self.database_backend == DatabaseBackend.POSTGRES:
return self
for name, path_value in self.projects.items():
path = Path(path_value)
for name, entry in self.projects.items():
path = Path(entry.path)
# Skip cloud-only projects whose path is a slug, not a local directory
if not path.is_absolute():
continue
if not path.exists():
try:
path.mkdir(parents=True)
@@ -368,8 +618,13 @@ class BasicMemoryConfig(BaseSettings):
return self
@property
def data_dir_path(self):
return Path.home() / DATA_DIR_NAME
def data_dir_path(self) -> Path:
"""Get app state directory for config and default SQLite database."""
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
return Path(config_dir)
home = os.getenv("HOME", Path.home())
return Path(home) / DATA_DIR_NAME
# Module-level cache for configuration
@@ -419,6 +674,33 @@ class ConfigManager:
try:
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
# Detect legacy format before model validators strip stale keys
_STALE_KEYS = {
"default_project_mode",
"project_modes",
"cloud_projects",
"cloud_mode",
}
needs_resave = bool(_STALE_KEYS & file_data.keys())
# Check if projects dict uses old string-value format
projects_raw = file_data.get("projects", {})
if projects_raw:
first_val = next(iter(projects_raw.values()), None)
if isinstance(first_val, str):
needs_resave = True
# Check if any project has local_sync_path set but path is a cloud slug
# (will be migrated by migrate_legacy_projects validator)
if not needs_resave:
for entry_data in projects_raw.values():
if isinstance(entry_data, dict):
lsp = entry_data.get("local_sync_path")
p = entry_data.get("path", "")
if lsp and not os.path.isabs(p):
needs_resave = True
break
# First, create config from environment variables (Pydantic will read them)
# Then overlay with file data for fields that aren't set via env vars
# This ensures env vars take precedence
@@ -440,10 +722,30 @@ class ConfigManager:
merged_data[field_name] = env_dict[field_name]
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
# Re-save to normalize legacy config into current format
if needs_resave:
# Create backup before overwriting so users can revert if needed
backup_path = self.config_file.with_suffix(".json.bak")
shutil.copy2(self.config_file, backup_path)
logger.info(f"Migrating config to current format (backup: {backup_path})")
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
return _CONFIG_CACHE
except json.JSONDecodeError as e: # pragma: no cover
logger.error(f"Invalid JSON in config file {self.config_file}: {e}")
raise SystemExit(
f"Error: config file is not valid JSON: {self.config_file}\n"
f" {e}\n"
f"Fix or delete the file and re-run."
)
except Exception as e: # pragma: no cover
logger.exception(f"Failed to load config: {e}")
raise e
logger.error(f"Failed to load config from {self.config_file}: {e}")
raise SystemExit(
f"Error: failed to load config from {self.config_file}\n"
f" {e}\n"
f"Fix or delete the file and re-run."
)
else:
config = BasicMemoryConfig()
self.save_config(config)
@@ -458,11 +760,15 @@ class ConfigManager:
@property
def projects(self) -> Dict[str, str]:
"""Get all configured projects."""
return self.config.projects.copy()
"""Get all configured projects as name -> path mapping.
Returns the legacy Dict[str, str] format for backward compatibility
with code that expects project name -> filesystem path.
"""
return {name: entry.path for name, entry in self.config.projects.items()}
@property
def default_project(self) -> str:
def default_project(self) -> Optional[str]:
"""Get the default project name."""
return self.config.default_project
@@ -472,13 +778,10 @@ class ConfigManager:
if project_name: # pragma: no cover
raise ValueError(f"Project '{name}' already exists")
# Ensure the path exists
project_path = Path(path)
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
# Load config, modify it, and save it
project_path = Path(path)
config = self.load_config()
config.projects[name] = str(project_path)
config.projects[name] = ProjectEntry(path=str(project_path))
self.save_config(config)
return ProjectConfig(name=name, home=project_path)
@@ -510,12 +813,15 @@ class ConfigManager:
self.save_config(config)
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
"""Look up a project from the configuration by name or permalink"""
"""Look up a project from the configuration by name or permalink.
Returns (project_name, path_string) for backward compatibility.
"""
project_permalink = generate_permalink(name)
app_config = self.config
for project_name, path in app_config.projects.items():
for project_name, entry in app_config.projects.items():
if project_permalink == generate_permalink(project_name):
return project_name, path
return project_name, entry.path
return None, None
@@ -550,14 +856,28 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
project_permalink = generate_permalink(actual_project_name)
for name, path in app_config.projects.items():
for name, entry in app_config.projects.items():
if project_permalink == generate_permalink(name):
return ProjectConfig(name=name, home=Path(path))
return ProjectConfig(name=name, home=Path(entry.path))
# otherwise raise error
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
def has_cloud_credentials(config: BasicMemoryConfig) -> bool:
"""Check if cloud credentials are available (API key or OAuth token).
Shared utility used by both MCP tools and CLI commands to determine
whether cloud project discovery is possible.
"""
if config.cloud_api_key:
return True
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
return auth.load_tokens() is not None
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
+132 -8
View File
@@ -44,6 +44,101 @@ _engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
async def _needs_semantic_embedding_backfill(
app_config: BasicMemoryConfig,
session_maker: async_sessionmaker[AsyncSession],
) -> bool:
"""Check if entities exist but vector embeddings are empty.
This is the reliable way to detect that embeddings need to be generated,
regardless of how migrations were applied (fresh DB, upgrade, reset, etc.).
"""
if not app_config.semantic_search_enabled:
return False
try:
async with scoped_session(session_maker) as session:
entity_count = (
await session.execute(text("SELECT COUNT(*) FROM entity"))
).scalar() or 0
if entity_count == 0:
return False
# Check if vector chunks table exists and is empty
embedding_count = (
await session.execute(text("SELECT COUNT(*) FROM search_vector_chunks"))
).scalar() or 0
return embedding_count == 0
except Exception as exc:
# Table might not exist yet (pre-migration)
logger.debug(f"Could not check embedding status: {exc}")
return False
async def _run_semantic_embedding_backfill(
app_config: BasicMemoryConfig,
session_maker: async_sessionmaker[AsyncSession],
) -> None:
"""Backfill semantic embeddings for all active projects/entities."""
if not app_config.semantic_search_enabled:
logger.info("Skipping automatic semantic embedding backfill: semantic search is disabled.")
return
async with scoped_session(session_maker) as session:
project_result = await session.execute(
text("SELECT id, name FROM project WHERE is_active = :is_active ORDER BY id"),
{"is_active": True},
)
projects = [(int(row[0]), str(row[1])) for row in project_result.fetchall()]
if not projects:
logger.info("Skipping automatic semantic embedding backfill: no active projects found.")
return
repository_class = (
PostgresSearchRepository
if app_config.database_backend == DatabaseBackend.POSTGRES
else SQLiteSearchRepository
)
total_entities = 0
for project_id, project_name in projects:
async with scoped_session(session_maker) as session:
entity_result = await session.execute(
text("SELECT id FROM entity WHERE project_id = :project_id ORDER BY id"),
{"project_id": project_id},
)
entity_ids = [int(row[0]) for row in entity_result.fetchall()]
if not entity_ids:
continue
total_entities += len(entity_ids)
logger.info(
"Automatic semantic embedding backfill: "
f"project={project_name}, entities={len(entity_ids)}"
)
search_repository = repository_class(
session_maker,
project_id=project_id,
app_config=app_config,
)
batch_result = await search_repository.sync_entity_vectors_batch(entity_ids)
if batch_result.entities_failed > 0:
logger.warning(
"Automatic semantic embedding backfill encountered entity failures: "
f"project={project_name}, failed={batch_result.entities_failed}, "
f"failed_entity_ids={batch_result.failed_entity_ids}"
)
logger.info(
"Automatic semantic embedding backfill complete: "
f"projects={len(projects)}, entities={total_entities}"
)
class DatabaseType(Enum):
"""Types of supported databases."""
@@ -344,24 +439,33 @@ async def engine_session_factory(
global _engine, _session_maker
# Use the same helper function as production code
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
# Use the same helper function as production code.
#
# Keep local references so teardown can deterministically dispose the
# specific engine created by this context manager, even if other code calls
# shutdown_db() and mutates module-level globals mid-test.
created_engine, created_session_maker = _create_engine_and_session(db_path, db_type, config)
_engine, _session_maker = created_engine, created_session_maker
try:
# Verify that engine and session maker are initialized
if _engine is None: # pragma: no cover
if created_engine is None: # pragma: no cover
logger.error("Database engine is None in engine_session_factory")
raise RuntimeError("Database engine initialization failed")
if _session_maker is None: # pragma: no cover
if created_session_maker is None: # pragma: no cover
logger.error("Session maker is None in engine_session_factory")
raise RuntimeError("Session maker initialization failed")
yield _engine, _session_maker
yield created_engine, created_session_maker
finally:
if _engine:
await _engine.dispose()
await created_engine.dispose()
# Only clear module-level globals if they still point to this context's
# engine/session. This avoids clobbering newer globals from other callers.
if _engine is created_engine:
_engine = None
if _session_maker is created_session_maker:
_session_maker = None
@@ -374,6 +478,7 @@ async def run_migrations(
so it's safe to call this multiple times - it will only run pending migrations.
"""
logger.info("Running database migrations...")
temp_engine: AsyncEngine | None = None
try:
# Get the absolute path to the alembic directory relative to this file
alembic_dir = Path(__file__).parent / "alembic"
@@ -398,7 +503,9 @@ async def run_migrations(
# Get session maker - ensure we don't trigger recursive migration calls
if _session_maker is None:
_, session_maker = _create_engine_and_session(app_config.database_path, database_type)
temp_engine, session_maker = _create_engine_and_session(
app_config.database_path, database_type, app_config
)
else:
session_maker = _session_maker
@@ -413,6 +520,23 @@ async def run_migrations(
await PostgresSearchRepository(session_maker, 1).init_search_index()
else:
await SQLiteSearchRepository(session_maker, 1).init_search_index()
# Check if backfill is needed — actual backfill runs in background
# from the MCP server lifespan to avoid blocking startup.
if await _needs_semantic_embedding_backfill(app_config, session_maker):
logger.info(
"Semantic embeddings missing — backfill will run in background after startup"
)
else:
logger.info("Semantic embeddings: up to date")
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
finally:
# Trigger: run_migrations() created a temporary engine while module-level
# session maker was not initialized.
# Why: temporary aiosqlite worker threads can outlive CLI command execution
# and block process shutdown if the engine is not disposed.
# Outcome: always dispose temporary engines after migration work completes.
if temp_engine is not None:
await temp_engine.dispose()
+72 -12
View File
@@ -41,7 +41,12 @@ async def get_chatgpt_importer(
file_service: FileServiceDep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
@@ -53,7 +58,12 @@ async def get_chatgpt_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with v2 dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
@@ -65,7 +75,12 @@ async def get_chatgpt_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with v2 external_id dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
@@ -80,7 +95,12 @@ async def get_claude_conversations_importer(
file_service: FileServiceDep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterDep = Annotated[
@@ -94,7 +114,12 @@ async def get_claude_conversations_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with v2 dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterV2Dep = Annotated[
@@ -108,7 +133,12 @@ async def get_claude_conversations_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterV2ExternalDep = Annotated[
@@ -125,7 +155,12 @@ async def get_claude_projects_importer(
file_service: FileServiceDep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
@@ -137,7 +172,12 @@ async def get_claude_projects_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with v2 dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterV2Dep = Annotated[
@@ -151,7 +191,12 @@ async def get_claude_projects_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterV2ExternalDep = Annotated[
@@ -168,7 +213,12 @@ async def get_memory_json_importer(
file_service: FileServiceDep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
@@ -180,7 +230,12 @@ async def get_memory_json_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with v2 dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
@@ -192,7 +247,12 @@ async def get_memory_json_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with v2 external_id dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterV2ExternalDep = Annotated[
+7 -3
View File
@@ -13,6 +13,7 @@ from typing import Annotated
from fastapi import Depends
from basic_memory.deps.config import AppConfigDep
from basic_memory.deps.db import SessionMakerDep
from basic_memory.deps.projects import (
ProjectIdDep,
@@ -147,13 +148,14 @@ RelationRepositoryV2ExternalDep = Annotated[
async def get_search_repository(
session_maker: SessionMakerDep,
project_id: ProjectIdDep,
app_config: AppConfigDep,
) -> SearchRepository:
"""Create a backend-specific SearchRepository instance for the current project.
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
based on database backend configuration.
"""
return create_search_repository(session_maker, project_id=project_id)
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
@@ -162,9 +164,10 @@ SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)
async def get_search_repository_v2( # pragma: no cover
session_maker: SessionMakerDep,
project_id: ProjectIdPathDep,
app_config: AppConfigDep,
) -> SearchRepository:
"""Create a SearchRepository instance for v2 API."""
return create_search_repository(session_maker, project_id=project_id)
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
@@ -173,9 +176,10 @@ SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repositor
async def get_search_repository_v2_external(
session_maker: SessionMakerDep,
project_id: ProjectExternalIdPathDep,
app_config: AppConfigDep,
) -> SearchRepository:
"""Create a SearchRepository instance for v2 API (uses external_id)."""
return create_search_repository(session_maker, project_id=project_id)
return create_search_repository(session_maker, project_id=project_id, app_config=app_config)
SearchRepositoryV2ExternalDep = Annotated[
+51 -5
View File
@@ -8,6 +8,8 @@ This module provides service-layer dependencies:
"""
import asyncio
import os
from pathlib import Path
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol
from fastapi import Depends
@@ -307,11 +309,13 @@ async def get_context_service(
search_repository: SearchRepositoryDep,
entity_repository: EntityRepositoryDep,
observation_repository: ObservationRepositoryDep,
link_resolver: LinkResolverDep,
) -> ContextService:
return ContextService(
search_repository=search_repository,
entity_repository=entity_repository,
observation_repository=observation_repository,
link_resolver=link_resolver,
)
@@ -322,12 +326,14 @@ async def get_context_service_v2( # pragma: no cover
search_repository: SearchRepositoryV2Dep,
entity_repository: EntityRepositoryV2Dep,
observation_repository: ObservationRepositoryV2Dep,
link_resolver: LinkResolverV2Dep,
) -> ContextService:
"""Create ContextService for v2 API."""
return ContextService(
search_repository=search_repository,
entity_repository=entity_repository,
observation_repository=observation_repository,
link_resolver=link_resolver,
)
@@ -338,12 +344,14 @@ async def get_context_service_v2_external(
search_repository: SearchRepositoryV2ExternalDep,
entity_repository: EntityRepositoryV2ExternalDep,
observation_repository: ObservationRepositoryV2ExternalDep,
link_resolver: LinkResolverV2ExternalDep,
) -> ContextService:
"""Create ContextService for v2 API (uses external_id)."""
return ContextService(
search_repository=search_repository,
entity_repository=entity_repository,
observation_repository=observation_repository,
link_resolver=link_resolver,
)
@@ -446,13 +454,22 @@ def _log_task_failure(completed: asyncio.Task) -> None:
class LocalTaskScheduler:
"""Default scheduler that runs tasks in-process via asyncio.create_task."""
"""Default scheduler that runs tasks in-process via asyncio.create_task.
In test mode (BASIC_MEMORY_ENV=test), tasks run as no-ops to avoid
background asyncio tasks racing against test teardown and causing
SQLite 'cannot commit transaction' errors.
"""
def __init__(
self,
handlers: Mapping[str, Callable[..., Coroutine[Any, Any, None]]],
test_mode: bool | None = None,
) -> None:
self._handlers = handlers
self._test_mode = (
test_mode if test_mode is not None else os.environ.get("BASIC_MEMORY_ENV") == "test"
)
def schedule(self, task_name: str, **payload: Any) -> None:
handler = self._handlers.get(task_name)
@@ -461,6 +478,15 @@ class LocalTaskScheduler:
# Outcome: fail fast to surface misconfiguration
if not handler:
raise ValueError(f"Unknown task name: {task_name}")
# Trigger: running inside pytest (BASIC_MEMORY_ENV=test)
# Why: background create_task() outlives test fixtures and races
# against engine disposal, causing flaky SQLite errors
# Outcome: skip background scheduling; tests exercise the sync
# codepaths directly when they need to
if self._test_mode:
return
task = asyncio.create_task(handler(**payload))
task.add_done_callback(_log_task_failure)
@@ -470,9 +496,12 @@ async def get_task_scheduler(
sync_service: SyncServiceV2ExternalDep,
search_service: SearchServiceV2ExternalDep,
project_config: ProjectConfigV2ExternalDep,
app_config: AppConfigDep,
) -> TaskScheduler:
"""Create a scheduler that maps task specs to coroutines."""
scheduler: LocalTaskScheduler | None = None
async def _reindex_entity(
entity_id: int,
resolve_relations: bool = False,
@@ -484,10 +513,18 @@ async def get_task_scheduler(
# Outcome: updates unresolved relations pointing to this entity
if resolve_relations:
await sync_service.resolve_relations(entity_id=entity_id)
# Trigger: semantic search enabled in local config.
# Why: vector chunks are derived and should refresh after canonical reindex completes.
# Outcome: schedules out-of-band vector sync without extending write latency.
if app_config.semantic_search_enabled and scheduler is not None:
scheduler.schedule("sync_entity_vectors", entity_id=entity_id)
async def _resolve_relations(entity_id: int, **_: Any) -> None:
await sync_service.resolve_relations(entity_id=entity_id)
async def _sync_entity_vectors(entity_id: int, **_: Any) -> None:
await search_service.sync_entity_vectors(entity_id)
async def _sync_project(force_full: bool = False, **_: Any) -> None:
await sync_service.sync(
project_config.home,
@@ -498,14 +535,17 @@ async def get_task_scheduler(
async def _reindex_project(**_: Any) -> None:
await search_service.reindex_all()
return LocalTaskScheduler(
scheduler = LocalTaskScheduler(
{
"reindex_entity": _reindex_entity,
"resolve_relations": _resolve_relations,
"sync_entity_vectors": _sync_entity_vectors,
"sync_project": _sync_project,
"reindex_project": _reindex_project,
}
},
test_mode=app_config.is_test_env,
)
return scheduler
TaskSchedulerDep = Annotated[TaskScheduler, Depends(get_task_scheduler)]
@@ -516,9 +556,15 @@ TaskSchedulerDep = Annotated[TaskScheduler, Depends(get_task_scheduler)]
async def get_project_service(
project_repository: ProjectRepositoryDep,
app_config: AppConfigDep,
) -> ProjectService:
"""Create ProjectService with repository."""
return ProjectService(repository=project_repository)
"""Create ProjectService with repository and a system-level FileService for directory operations."""
# A system-level FileService for project directory creation (no project-specific base_path needed).
# ensure_directory() accepts absolute paths and ignores base_path for those, so Path.home() is safe.
entity_parser = EntityParser(Path.home())
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
file_service = FileService(Path.home(), markdown_processor, app_config=app_config)
return ProjectService(repository=project_repository, file_service=file_service)
ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
+24
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityMarkdown
from basic_memory.schemas.importer import ImportResult
from basic_memory.utils import build_canonical_permalink, generate_permalink
if TYPE_CHECKING: # pragma: no cover
from basic_memory.services.file_service import FileService
@@ -29,6 +30,7 @@ class Importer[T: ImportResult]:
base_path: Path,
markdown_processor: MarkdownProcessor,
file_service: "FileService",
project_name: Optional[str] = None,
):
"""Initialize the import service.
@@ -40,6 +42,8 @@ class Importer[T: ImportResult]:
self.base_path = base_path.resolve() # Get absolute path
self.markdown_processor = markdown_processor
self.file_service = file_service
self.project_name = project_name
self.project_permalink = generate_permalink(project_name) if project_name else None
@abstractmethod
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
@@ -73,6 +77,26 @@ class Importer[T: ImportResult]:
# FileService.write_file handles directory creation and returns checksum
return await self.file_service.write_file(file_path, content)
def canonical_permalink(self, path: str) -> str:
"""Build a canonical permalink for imported content."""
include_project = True
# Trigger: importer has app config with permalink prefixing flag
# Why: imported notes should align with canonical permalink format
# Outcome: include project prefix when enabled
if self.file_service.app_config is not None:
include_project = self.file_service.app_config.permalinks_include_project
return build_canonical_permalink(
self.project_permalink,
path,
include_project=include_project,
)
def build_import_paths(self, path: str) -> tuple[str, str]:
"""Return (permalink, file_path) for an imported entity."""
permalink = self.canonical_permalink(path)
return permalink, f"{path}.md"
async def ensure_folder_exists(self, folder: str) -> None:
"""Ensure folder exists using FileService.
+13 -8
View File
@@ -51,11 +51,20 @@ class ChatGPTImporter(Importer[ChatImportResult]):
chats_imported = 0
for chat in conversations:
created_at = chat["create_time"]
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
clean_title = clean_filename(chat["title"])
relative_path = (
f"{destination_folder}/{date_prefix}-{clean_title}"
if destination_folder
else f"{date_prefix}-{clean_title}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Convert to entity
entity = self._format_chat_content(destination_folder, chat)
entity = self._format_chat_content(chat, permalink)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
# Count messages
@@ -83,7 +92,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
return self.handle_error("Failed to import ChatGPT conversations", e)
def _format_chat_content(
self, folder: str, conversation: Dict[str, Any]
self, conversation: Dict[str, Any], permalink: str
) -> EntityMarkdown: # pragma: no cover
"""Convert chat conversation to Basic Memory entity.
@@ -105,10 +114,6 @@ class ChatGPTImporter(Importer[ChatImportResult]):
root_id = node_id
break
# Generate permalink
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
clean_title = clean_filename(conversation["title"])
# Format content
content = self._format_chat_markdown(
title=conversation["title"],
@@ -126,7 +131,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
"title": conversation["title"],
"created": format_timestamp(created_at),
"modified": format_timestamp(modified_at),
"permalink": f"{folder}/{date_prefix}-{clean_title}",
"permalink": permalink,
}
),
content=content,
@@ -54,18 +54,27 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
for chat in conversations:
# Get name, providing default for unnamed conversations
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
date_prefix = datetime.fromisoformat(
chat["created_at"].replace("Z", "+00:00")
).strftime("%Y%m%d")
clean_title = clean_filename(chat_name)
relative_path = (
f"{destination_folder}/{date_prefix}-{clean_title}"
if destination_folder
else f"{date_prefix}-{clean_title}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Convert to entity
entity = self._format_chat_content(
folder=destination_folder,
name=chat_name,
messages=chat["chat_messages"],
created_at=chat["created_at"],
modified_at=chat["updated_at"],
permalink=permalink,
)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
chats_imported += 1
@@ -84,11 +93,11 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
def _format_chat_content(
self,
folder: str,
name: str,
messages: List[Dict[str, Any]],
created_at: str,
modified_at: str,
permalink: str,
) -> EntityMarkdown:
"""Convert chat messages to Basic Memory entity format.
@@ -102,11 +111,6 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
Returns:
EntityMarkdown instance representing the conversation.
"""
# Generate permalink using folder name (relative path)
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
clean_title = clean_filename(name)
permalink = f"{folder}/{date_prefix}-{clean_title}"
# Format content
content = self._format_chat_markdown(
name=name,
@@ -63,17 +63,28 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
await self.file_service.ensure_directory(docs_dir)
# Import prompt template if it exists
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
# Write file using relative path - FileService handles base_path
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(prompt_entity, file_path)
if project.get("prompt_template"):
prompt_path = (
f"{destination_folder}/{project_dir}/prompt-template"
if destination_folder
else f"{project_dir}/prompt-template"
)
permalink, file_path = self.build_import_paths(prompt_path)
prompt_entity = self._format_prompt_markdown(project, permalink)
if prompt_entity:
await self.write_entity(prompt_entity, file_path)
prompts_imported += 1
# Import project documents
for doc in project.get("docs", []):
entity = self._format_project_markdown(project, doc, destination_folder)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
doc_file = clean_filename(doc["filename"])
doc_path = (
f"{destination_folder}/{project_dir}/docs/{doc_file}"
if destination_folder
else f"{project_dir}/docs/{doc_file}"
)
permalink, file_path = self.build_import_paths(doc_path)
entity = self._format_project_markdown(project, doc, permalink)
await self.write_entity(entity, file_path)
docs_imported += 1
@@ -89,7 +100,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
return self.handle_error("Failed to import Claude projects", e)
def _format_project_markdown(
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
self, project: Dict[str, Any], doc: Dict[str, Any], permalink: str
) -> EntityMarkdown:
"""Format a project document as a Basic Memory entity.
@@ -105,17 +116,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
created_at = doc.get("created_at") or project["created_at"]
modified_at = project["updated_at"]
# Generate clean names for organization
project_dir = clean_filename(project["name"])
doc_file = clean_filename(doc["filename"])
# Build permalink with optional destination folder prefix
permalink = (
f"{destination_folder}/{project_dir}/docs/{doc_file}"
if destination_folder
else f"{project_dir}/docs/{doc_file}"
)
# Create entity
entity = EntityMarkdown(
frontmatter=EntityFrontmatter(
@@ -136,7 +136,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
return entity
def _format_prompt_markdown(
self, project: Dict[str, Any], destination_folder: str = ""
self, project: Dict[str, Any], permalink: str
) -> Optional[EntityMarkdown]:
"""Format project prompt template as a Basic Memory entity.
@@ -155,16 +155,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
created_at = project["created_at"]
modified_at = project["updated_at"]
# Generate clean project directory name
project_dir = clean_filename(project["name"])
# Build permalink with optional destination folder prefix
permalink = (
f"{destination_folder}/{project_dir}/prompt-template"
if destination_folder
else f"{project_dir}/prompt-template"
)
# Create entity
entity = EntityMarkdown(
frontmatter=EntityFrontmatter(
@@ -80,11 +80,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
# Build permalink with optional destination folder prefix
permalink = (
relative_path = (
f"{destination_folder}/{entity_type}/{name}"
if destination_folder
else f"{entity_type}/{name}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Ensure entity type directory exists using FileService with relative path
entity_type_dir = (
@@ -109,7 +110,6 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
entities_created += 1
+34 -5
View File
@@ -88,6 +88,22 @@ def normalize_frontmatter_value(value: Any) -> Any:
return value
def _coerce_to_string(value: Any) -> str:
"""Coerce a frontmatter value to a string.
YAML can parse scalar-looking fields as lists when the author uses block
sequence syntax. For fields like ``title`` and ``type`` that *must* be
strings, this helper converts lists to a comma-separated string and any
other non-string type via ``str()``.
"""
if isinstance(value, str):
return value
if isinstance(value, list):
# Join list items, converting each to string first
return ", ".join(str(item) for item in value)
return str(value)
def normalize_frontmatter_metadata(metadata: dict) -> dict:
"""Normalize all values in frontmatter metadata dict.
@@ -233,9 +249,15 @@ class EntityParser:
content = strip_bom(content)
# Parse frontmatter with proper error handling for malformed YAML
# Parse frontmatter with proper error handling for malformed YAML.
# We use frontmatter.parse() instead of frontmatter.loads() because
# loads() does Post(content, handler, **metadata), which crashes when
# the YAML contains reserved keys like 'content' or 'handler'.
# See basic-memory-cloud#375.
try:
post = frontmatter.loads(content)
fm_metadata, fm_content = frontmatter.parse(content)
post = frontmatter.Post(fm_content)
post.metadata.update(fm_metadata)
except yaml.YAMLError as e:
logger.warning(
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
@@ -248,15 +270,22 @@ class EntityParser:
# Normalize frontmatter values
metadata = normalize_frontmatter_metadata(post.metadata)
# Ensure required fields have defaults
# Ensure required string fields are always strings.
# YAML can parse these as lists when authors use block sequence syntax
# (e.g. "title:\n - My Title"), causing 'list' has no attribute 'strip'
# downstream. See basic-memory-cloud#376.
title = metadata.get("title")
if title is not None:
title = _coerce_to_string(title)
if not title or title == "None":
metadata["title"] = file_path.stem
else:
metadata["title"] = title
entity_type = metadata.get("type")
metadata["type"] = entity_type if entity_type is not None else "note"
note_type = metadata.get("type")
if note_type is not None:
note_type = _coerce_to_string(note_type)
metadata["type"] = note_type if note_type is not None else "note"
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
if tags:
+4 -2
View File
@@ -1,6 +1,8 @@
"""Markdown-it plugins for Basic Memory markdown parsing."""
from typing import List, Any, Dict
from basic_memory.utils import normalize_project_reference
from markdown_it import MarkdownIt
from markdown_it.token import Token
@@ -114,7 +116,7 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
rel_type = before
# Get target
target = content[start + 2 : end].strip()
target = normalize_project_reference(content[start + 2 : end].strip())
# Look for context after
after = content[end + 2 :].strip()
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
# No matching ]] found
break
target = content[start + 2 : end].strip()
target = normalize_project_reference(content[start + 2 : end].strip())
if target:
relations.append({"type": "links_to", "target": target, "context": None})
+3 -3
View File
@@ -50,7 +50,7 @@ def entity_model_from_markdown(
# Update basic fields
model.title = markdown.frontmatter.title
model.entity_type = markdown.frontmatter.type
model.note_type = markdown.frontmatter.type
# Only update permalink if it exists in frontmatter, otherwise preserve existing
if markdown.frontmatter.permalink is not None:
model.permalink = markdown.frontmatter.permalink
@@ -86,7 +86,7 @@ async def schema_to_markdown(schema: Any) -> Post:
Convert schema to markdown Post object.
Args:
schema: Schema to convert (must have title, entity_type, and permalink attributes)
schema: Schema to convert (must have title, note_type, and permalink attributes)
Returns:
Post object with frontmatter metadata
@@ -113,7 +113,7 @@ async def schema_to_markdown(schema: Any) -> Post:
post = Post(
content,
title=schema.title,
type=schema.entity_type,
type=schema.note_type,
)
# set the permalink if passed in
if schema.permalink:
+167 -125
View File
@@ -1,169 +1,211 @@
import os
from contextlib import asynccontextmanager, AbstractAsyncContextManager
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import AsyncIterator, Callable, Optional
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
from basic_memory.config import ConfigManager, ProjectMode
def _force_local_mode() -> bool:
"""Check if local mode is forced via environment variable.
This allows commands like `bm mcp` to force local routing even when
cloud_mode_enabled is True in config. The local MCP server should
always talk to the local API, not the cloud proxy.
Returns:
True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value
"""
"""Check if local mode is forced via environment variable."""
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
def _force_cloud_mode() -> bool:
"""Check if cloud mode is forced via environment variable."""
return os.environ.get("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes")
def _explicit_routing() -> bool:
"""Check if CLI --local/--cloud flag was explicitly passed."""
return os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING", "").lower() in ("true", "1", "yes")
def _build_timeout() -> Timeout:
"""Create a standard timeout config used across all clients."""
return Timeout(
connect=10.0,
read=30.0,
write=30.0,
pool=30.0,
)
def _asgi_client(timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client."""
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
async def _resolve_cloud_token(config) -> str:
"""Resolve cloud token with API key preferred, OAuth fallback."""
token = config.cloud_api_key
if token:
return token
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
token = await auth.get_valid_token()
if token:
return token
raise RuntimeError(
"Cloud routing requested but no credentials found. "
"Run 'bm cloud api-key save <key>' or 'bm cloud login' first."
)
@asynccontextmanager
async def _cloud_client(
config,
timeout: Timeout,
workspace: Optional[str] = None,
) -> AsyncIterator[AsyncClient]:
"""Create a cloud proxy client with resolved credentials."""
token = await _resolve_cloud_token(config)
proxy_base_url = f"{config.cloud_host}/proxy"
headers = {"Authorization": f"Bearer {token}"}
if workspace:
headers["X-Workspace-ID"] = workspace
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
async with AsyncClient(
base_url=proxy_base_url,
headers=headers,
timeout=timeout,
) as client:
yield client
@asynccontextmanager
async def get_cloud_control_plane_client() -> AsyncIterator[AsyncClient]:
"""Create a control-plane cloud client for endpoints outside /proxy."""
config = ConfigManager().config
timeout = _build_timeout()
token = await _resolve_cloud_token(config)
logger.info(f"Creating HTTP client for cloud control plane at: {config.cloud_host}")
async with AsyncClient(
base_url=config.cloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
) as client:
yield client
# Optional factory override for dependency injection
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
"""Override the default client factory (for cloud app, testing, etc).
Args:
factory: An async context manager that yields an AsyncClient
Example:
@asynccontextmanager
async def custom_client_factory():
async with AsyncClient(...) as client:
yield client
set_client_factory(custom_client_factory)
"""
"""Override the default client factory (for cloud app, testing, etc)."""
global _client_factory
_client_factory = factory
def is_factory_mode() -> bool:
"""Return True when a client factory override is active (e.g., cloud app)."""
return _client_factory is not None
@asynccontextmanager
async def get_client() -> AsyncIterator[AsyncClient]:
async def get_cloud_proxy_client(
workspace: Optional[str] = None,
) -> AsyncIterator[AsyncClient]:
"""Create a cloud proxy client for project-level operations.
Used by MCP tools to fetch cloud project lists independently of the
default get_client() routing, which always goes through the local ASGI
transport in stdio mode.
"""
config = ConfigManager().config
timeout = _build_timeout()
async with _cloud_client(config, timeout, workspace=workspace) as client:
yield client
@asynccontextmanager
async def get_client(
project_name: Optional[str] = None,
workspace: Optional[str] = None,
) -> AsyncIterator[AsyncClient]:
"""Get an AsyncClient as a context manager.
This function provides proper resource management for HTTP clients,
ensuring connections are closed after use. It supports three modes:
1. **Factory injection** (cloud app, tests):
If a custom factory is set via set_client_factory(), use that.
2. **CLI cloud mode**:
When cloud_mode_enabled is True, create HTTP client with auth
token from CLIAuth for requests to cloud proxy endpoint.
3. **Local mode** (default):
Use ASGI transport for in-process requests to local FastAPI app.
Usage:
async with get_client() as client:
response = await client.get("/path")
Yields:
AsyncClient: Configured HTTP client for the current mode
Raises:
RuntimeError: If cloud mode is enabled but user is not authenticated
Routing priority:
1. Factory injection.
2. Explicit routing flags (--local/--cloud).
3. Per-project mode routing when project_name is provided.
4. Local ASGI transport by default.
"""
if _client_factory:
# Use injected factory (cloud app, tests)
async with _client_factory() as client:
yield client
else:
# Default: create based on config
config = ConfigManager().config
timeout = Timeout(
connect=10.0, # 10 seconds for connection
read=30.0, # 30 seconds for reading response
write=30.0, # 30 seconds for writing request
pool=30.0, # 30 seconds for connection pool
)
return
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
# Why: allows local MCP server and CLI commands to route locally
# even when cloud_mode_enabled is True
# Outcome: uses ASGI transport for in-process local API calls
config = ConfigManager().config
timeout = _build_timeout()
# --- Explicit routing override ---
# Trigger: user passed --local/--cloud.
# Why: command-level override should be deterministic and bypass project mode.
# Outcome: route strictly based on explicit flag.
if _explicit_routing():
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
logger.debug("Explicit local routing enabled - using ASGI client")
async with _asgi_client(timeout) as client:
yield client
elif config.cloud_mode_enabled:
# CLI cloud mode: inject auth when creating client
from basic_memory.cli.auth import CLIAuth
return
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
token = await auth.get_valid_token()
if _force_cloud_mode():
logger.debug("Explicit cloud routing enabled - using cloud proxy client")
async with _cloud_client(config, timeout, workspace=workspace) as client:
yield client
return
if not token:
# --- Per-project routing ---
# Trigger: project_name provided without explicit routing override.
# Why: project mode is the source of truth for project-scoped commands.
# Outcome: route via project.mode (CLOUD/LOCAL).
if project_name is not None and not _explicit_routing():
project_mode = config.get_project_mode(project_name)
if project_mode == ProjectMode.CLOUD:
logger.debug(f"Project '{project_name}' is cloud mode - using cloud proxy client")
try:
async with _cloud_client(config, timeout, workspace=workspace) as client:
yield client
except RuntimeError as exc:
raise RuntimeError(
"Cloud mode enabled but not authenticated. "
"Run 'basic-memory cloud login' first."
)
f"Project '{project_name}' is set to cloud mode but no credentials found. "
"Run 'bm cloud api-key save <key>' or 'bm cloud login' first."
) from exc
return
# Auth header set ONCE at client creation
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
async with AsyncClient(
base_url=proxy_base_url,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
) as client:
yield client
else:
# Local mode: ASGI transport for in-process calls
# Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed
logger.info("Creating ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
logger.debug(f"Project '{project_name}' is local mode - using ASGI client")
async with _asgi_client(timeout) 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:
yield client
def create_client() -> AsyncClient:
"""Create an HTTP client based on configuration.
"""Create an HTTP client based on explicit routing flags.
DEPRECATED: Use get_client() context manager instead for proper resource management.
This function is kept for backward compatibility but will be removed in a future version.
The returned client should be closed manually by calling await client.aclose().
Returns:
AsyncClient configured for either local ASGI or remote proxy
"""
config_manager = ConfigManager()
config = config_manager.config
timeout = _build_timeout()
# Configure timeout for longer operations like write_note
# Default httpx timeout is 5 seconds which is too short for file operations
timeout = Timeout(
connect=10.0, # 10 seconds for connection
read=30.0, # 30 seconds for reading response
write=30.0, # 30 seconds for writing request
pool=30.0, # 30 seconds for connection pool
)
# Check force local first (for local MCP server and CLI --local flag)
if _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
elif config.cloud_mode_enabled:
# Use HTTP transport to proxy endpoint
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
else:
# Default: use ASGI transport for local API (development mode)
if _force_local_mode() or not _force_cloud_mode():
logger.info("Creating ASGI client for local Basic Memory API")
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
return _asgi_client(timeout)
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
config = ConfigManager().config
proxy_base_url = f"{config.cloud_host}/proxy"
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
+2
View File
@@ -17,6 +17,7 @@ from basic_memory.mcp.clients.memory import MemoryClient
from basic_memory.mcp.clients.directory import DirectoryClient
from basic_memory.mcp.clients.resource import ResourceClient
from basic_memory.mcp.clients.project import ProjectClient
from basic_memory.mcp.clients.schema import SchemaClient
__all__ = [
"KnowledgeClient",
@@ -25,4 +26,5 @@ __all__ = [
"DirectoryClient",
"ResourceClient",
"ProjectClient",
"SchemaClient",
]
+3 -2
View File
@@ -224,11 +224,12 @@ class KnowledgeClient:
# --- Resolution ---
async def resolve_entity(self, identifier: str) -> str:
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
"""Resolve a string identifier to an entity external_id.
Args:
identifier: The identifier to resolve (permalink, title, or path)
strict: If True, require exact matching (no fuzzy fallback)
Returns:
The resolved entity external_id (UUID)
@@ -239,7 +240,7 @@ class KnowledgeClient:
response = await call_post(
self.http_client,
f"{self._base_path}/resolve",
json={"identifier": identifier},
json={"identifier": identifier, "strict": strict},
)
data = response.json()
return data["external_id"]
+143 -3
View File
@@ -7,8 +7,16 @@ from typing import Any
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
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
class ProjectClient:
@@ -70,11 +78,14 @@ class ProjectClient:
)
return ProjectStatusResponse.model_validate(response.json())
async def delete_project(self, project_external_id: str) -> ProjectStatusResponse:
async def delete_project(
self, project_external_id: str, delete_notes: bool = False
) -> ProjectStatusResponse:
"""Delete a project by its external ID.
Args:
project_external_id: Project external ID (UUID)
delete_notes: If True, also delete project files from disk
Returns:
ProjectStatusResponse with deletion result
@@ -82,8 +93,137 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
url = f"/v2/projects/{project_external_id}"
if delete_notes:
url += "?delete_notes=true"
response = await call_delete(
self.http_client,
f"/v2/projects/{project_external_id}",
url,
)
return ProjectStatusResponse.model_validate(response.json())
async def resolve_project(self, identifier: str) -> ProjectResolveResponse:
"""Resolve a project name/permalink to its full project record.
Args:
identifier: Project name or permalink
Returns:
ProjectResolveResponse with project metadata
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
"/v2/projects/resolve",
json={"identifier": identifier},
)
return ProjectResolveResponse.model_validate(response.json())
async def set_default(self, project_external_id: str) -> ProjectStatusResponse:
"""Set a project as the default.
Args:
project_external_id: Project external ID (UUID)
Returns:
ProjectStatusResponse with result
Raises:
ToolError: If the request fails
"""
response = await call_put(
self.http_client,
f"/v2/projects/{project_external_id}/default",
)
return ProjectStatusResponse.model_validate(response.json())
async def update_project(
self, project_external_id: str, data: dict[str, Any]
) -> ProjectStatusResponse:
"""Update a project's configuration (e.g. path).
Args:
project_external_id: Project external ID (UUID)
data: Fields to update
Returns:
ProjectStatusResponse with update result
Raises:
ToolError: If the request fails
"""
response = await call_patch(
self.http_client,
f"/v2/projects/{project_external_id}",
json=data,
)
return ProjectStatusResponse.model_validate(response.json())
async def sync(
self,
project_external_id: str,
force_full: bool = False,
run_in_background: bool = True,
) -> dict[str, Any]:
"""Trigger a sync operation for a project.
Args:
project_external_id: Project external ID (UUID)
force_full: If True, force a full scan bypassing watermark optimization
run_in_background: If True, return immediately; if False, wait for completion
Returns:
Raw response dict — background mode returns {"message": ...},
foreground mode returns a SyncReportResponse-shaped dict.
Raises:
ToolError: If the request fails
"""
url = f"/v2/projects/{project_external_id}/sync"
params = []
if force_full:
params.append("force_full=true")
if not run_in_background:
params.append("run_in_background=false")
if params:
url += "?" + "&".join(params)
response = await call_post(self.http_client, url)
return response.json()
async def get_status(self, project_external_id: str) -> SyncReportResponse:
"""Get the sync status for a project.
Args:
project_external_id: Project external ID (UUID)
Returns:
SyncReportResponse describing pending changes
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"/v2/projects/{project_external_id}/status",
)
return SyncReportResponse.model_validate(response.json())
async def get_info(self, project_external_id: str) -> ProjectInfoResponse:
"""Get detailed project information and statistics.
Args:
project_external_id: Project external ID (UUID)
Returns:
ProjectInfoResponse with project details
Raises:
ToolError: If the request fails
"""
response = await call_get(
self.http_client,
f"/v2/projects/{project_external_id}/info",
)
return ProjectInfoResponse.model_validate(response.json())
+113
View File
@@ -0,0 +1,113 @@
"""Typed client for schema API operations.
Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
"""
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.schemas.schema import (
ValidationReport,
InferenceReport,
DriftReport,
)
class SchemaClient:
"""Typed client for schema operations.
Centralizes:
- API path construction for /v2/projects/{project_id}/schema/*
- Response validation via Pydantic models
- Consistent error handling through call_* utilities
Usage:
async with get_client() as http_client:
client = SchemaClient(http_client, project_id)
report = await client.validate(note_type="person")
"""
def __init__(self, http_client: AsyncClient, project_id: str):
"""Initialize the schema client.
Args:
http_client: HTTPX AsyncClient for making requests
project_id: Project external_id (UUID) for API calls
"""
self.http_client = http_client
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/schema"
async def validate(
self,
*,
note_type: str | None = None,
identifier: str | None = None,
) -> ValidationReport:
"""Validate notes against their resolved schemas.
Args:
note_type: Optional note type to batch-validate
identifier: Optional specific note to validate
Returns:
ValidationReport with per-note results
Raises:
ToolError: If the request fails
"""
params: dict[str, str] = {}
if note_type:
params["note_type"] = note_type
if identifier:
params["identifier"] = identifier
response = await call_post(
self.http_client,
f"{self._base_path}/validate",
params=params,
)
return ValidationReport.model_validate(response.json())
async def infer(
self,
note_type: str,
*,
threshold: float = 0.25,
) -> InferenceReport:
"""Infer a schema from existing notes of a given type.
Args:
note_type: The note type to analyze
threshold: Minimum frequency for optional fields (0-1)
Returns:
InferenceReport with frequency data and suggested schema
Raises:
ToolError: If the request fails
"""
response = await call_post(
self.http_client,
f"{self._base_path}/infer",
params={"note_type": note_type, "threshold": threshold},
)
return InferenceReport.model_validate(response.json())
async def diff(self, note_type: str) -> DriftReport:
"""Show drift between schema definition and actual usage.
Args:
note_type: The note type to check for drift
Returns:
DriftReport with detected differences
Raises:
ToolError: If the request fails
"""
response = await call_get(
self.http_client,
f"{self._base_path}/diff/{note_type}",
)
return DriftReport.model_validate(response.json())
-1
View File
@@ -39,7 +39,6 @@ class McpContainer:
"""
config = ConfigManager().config
mode = resolve_runtime_mode(
cloud_mode_enabled=config.cloud_mode_enabled,
is_test_env=config.is_test_env,
)
return cls(config=config, mode=mode)
+162
View File
@@ -0,0 +1,162 @@
"""Formatting helpers for MCP tool outputs."""
from __future__ import annotations
from typing import Sequence
from basic_memory.schemas.search import SearchResponse, SearchResult
ANSI_RESET = "\x1b[0m"
ANSI_BOLD = "\x1b[1m"
ANSI_DIM = "\x1b[2m"
ANSI_CYAN = "\x1b[36m"
def _apply_style(text: str, style: str, enabled: bool) -> str:
if not enabled:
return text
return f"{style}{text}{ANSI_RESET}"
def _strip_frontmatter(text: str) -> str:
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return text
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
return "\n".join(lines[idx + 1 :]).lstrip()
return text
def _parse_title(text: str) -> str | None:
for line in text.splitlines():
if line.startswith("# "):
return line[2:].strip()
return None
def _truncate(text: str, width: int) -> str:
if width <= 0:
return ""
if len(text) <= width:
return text
if width <= 3:
return text[:width]
return text[: width - 3] + "..."
def _make_separator(widths: Sequence[int]) -> str:
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
cells = []
for value, width in zip(values, widths, strict=True):
cells.append(f" {_truncate(value, width).ljust(width)} ")
return "|" + "|".join(cells) + "|"
def _get_result_tags(result: SearchResult) -> str:
metadata = result.metadata or {}
if isinstance(metadata, dict):
tags = metadata.get("tags")
if isinstance(tags, list):
return ", ".join(str(tag) for tag in tags if tag)
return ""
def _get_result_path(result: SearchResult) -> str:
return result.permalink or result.file_path or ""
def format_search_results_ascii(
result: SearchResponse,
query: str | None = None,
color: bool = False,
) -> str:
"""Format search results as an ASCII table for TUI clients."""
results = result.results or []
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header_line]
if query:
lines.append(f"Query: {query}")
summary = (
f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
)
lines.append(_apply_style(summary, ANSI_DIM, color))
if not results:
lines.append("No results.")
return "\n".join(lines).strip()
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
rows = []
for idx, item in enumerate(results, start=1):
rows.append(
[
str(idx),
item.title or "Untitled",
item.type.value if hasattr(item.type, "value") else str(item.type),
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
_get_result_path(item),
_get_result_tags(item),
]
)
max_widths = [3, 32, 10, 7, 36, 24]
widths = []
for index, header in enumerate(headers):
column_values = [header] + [row[index] for row in rows]
max_len = max(len(value) for value in column_values)
widths.append(min(max_widths[index], max_len))
table = [_make_separator(widths)]
header_row = _format_row(headers, widths)
if color:
header_cells = []
for value, width in zip(headers, widths, strict=True):
padded = f" {_truncate(value, width).ljust(width)} "
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
header_row = "|" + "|".join(header_cells) + "|"
table.append(header_row)
table.append(_make_separator(widths))
for row in rows:
table.append(_format_row(row, widths))
table.append(_make_separator(widths))
lines.append("")
lines.extend(table)
return "\n".join(lines).rstrip()
def format_note_preview_ascii(
content: str,
identifier: str | None = None,
color: bool = False,
) -> str:
"""Format note content for ASCII/TUI display."""
identifier = identifier or ""
cleaned = _strip_frontmatter(content)
title = _parse_title(cleaned) or identifier or "Note Preview"
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header, f"Title: {title}"]
if identifier:
lines.append(f"Identifier: {identifier}")
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
if content.strip():
lines.append(content.rstrip())
else:
lines.append("(empty note)")
return "\n".join(lines).rstrip()
+427 -31
View File
@@ -8,66 +8,99 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
from typing import Optional, List
from contextlib import asynccontextmanager
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple
from httpx import AsyncClient
from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.config import ConfigManager
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode
from basic_memory.project_resolver import ProjectResolver
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.schemas.v2 import ProjectResolveResponse
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import generate_permalink, normalize_project_reference
# --- 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,
# avoiding the control-plane HTTP round-trip that requires local credentials.
_workspace_provider: Optional[Callable[[], Awaitable[list[WorkspaceInfo]]]] = None
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
"""Override workspace discovery (for cloud app, testing, etc)."""
global _workspace_provider
_workspace_provider = provider
async def _resolve_default_project_from_api() -> Optional[str]:
"""Query the projects API for the default project.
Used as a fallback when ConfigManager has no local config (cloud mode).
"""
from basic_memory.mcp.async_client import get_client
try:
async with get_client() as client:
response = await client.get("/v2/projects/")
if response.status_code == 200:
project_list = ProjectList.model_validate(response.json())
if project_list.default_project:
return project_list.default_project
# Fallback: find project with is_default=True
for p in project_list.projects:
if p.is_default:
return p.name
except Exception:
pass
return None
async def resolve_project_parameter(
project: Optional[str] = None,
allow_discovery: bool = False,
cloud_mode: Optional[bool] = None,
default_project_mode: Optional[bool] = None,
default_project: Optional[str] = None,
) -> Optional[str]:
"""Resolve project parameter using three-tier hierarchy.
"""Resolve project parameter using unified linear priority chain.
This is a thin wrapper around ProjectResolver for backwards compatibility.
New code should consider using ProjectResolver directly for more detailed
resolution information.
if cloud_mode:
project is required (unless allow_discovery=True for tools that support discovery mode)
else:
Resolution order:
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
2. Explicit project parameter - medium priority
3. Default project if default_project_mode=true - lowest priority
Resolution order:
1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
2. EXPLICIT: project parameter passed directly
3. DEFAULT: default_project from config (if set)
4. Fallback: discovery (if allowed) → NONE
Args:
project: Optional explicit project parameter
allow_discovery: If True, allows returning None in cloud mode for discovery mode
allow_discovery: If True, allows returning None for discovery mode
(used by tools like recent_activity that can operate across all projects)
cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager.
default_project_mode: Optional explicit default project mode. If not provided, reads from ConfigManager.
default_project: Optional explicit default project. If not provided, reads from ConfigManager.
Returns:
Resolved project name or None if no resolution possible
"""
# Load config for any values not explicitly provided
if cloud_mode is None or default_project_mode is None or default_project is None:
# Load config for any values not explicitly provided.
# ConfigManager reads from the local config file, which doesn't exist in cloud mode.
# When it returns None, fall back to querying the projects API for the is_default flag.
if default_project is None:
config = ConfigManager().config
if cloud_mode is None:
cloud_mode = config.cloud_mode
if default_project_mode is None:
default_project_mode = config.default_project_mode
if default_project is None:
default_project = config.default_project
default_project = config.default_project
if default_project is None:
default_project = await _resolve_default_project_from_api()
# Create resolver with configuration and resolve
resolver = ProjectResolver.from_env(
cloud_mode=cloud_mode,
default_project_mode=default_project_mode,
default_project=default_project,
)
result = resolver.resolve(project=project, allow_discovery=allow_discovery)
@@ -83,6 +116,114 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
return [project.name for project in project_list.projects]
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
"""Return True when identifier matches workspace tenant_id or name."""
if workspace.tenant_id == identifier:
return True
return workspace.name.lower() == identifier.lower()
def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
"""Format deterministic workspace choices for prompt-style errors."""
return "\n".join(
[
(
f"- {item.name} "
f"(type={item.workspace_type}, role={item.role}, tenant_id={item.tenant_id})"
)
for item in workspaces
]
)
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
"""Load available cloud workspaces for the current authenticated user."""
if context:
cached_raw = await context.get_state("available_workspaces")
if isinstance(cached_raw, list):
return [WorkspaceInfo.model_validate(item) for item in cached_raw]
# Trigger: workspace provider was injected (e.g., by cloud MCP server)
# Why: the cloud server IS the cloud — it can query its own database
# directly instead of making an HTTP round-trip that requires local credentials
# Outcome: use provider result, cache in context, skip control-plane client
if _workspace_provider is not None:
workspaces = await _workspace_provider()
if context:
await context.set_state(
"available_workspaces",
[ws.model_dump() for ws in workspaces],
)
return workspaces
from basic_memory.mcp.async_client import get_cloud_control_plane_client
from basic_memory.mcp.tools.utils import call_get
async with get_cloud_control_plane_client() as client:
response = await call_get(client, "/workspaces/")
workspace_list = WorkspaceListResponse.model_validate(response.json())
if context:
await context.set_state(
"available_workspaces",
[ws.model_dump() for ws in workspace_list.workspaces],
)
return workspace_list.workspaces
async def resolve_workspace_parameter(
workspace: Optional[str] = None,
context: Optional[Context] = None,
) -> WorkspaceInfo:
"""Resolve workspace using explicit input, session cache, and cloud discovery."""
if context:
cached_raw = await context.get_state("active_workspace")
if isinstance(cached_raw, dict):
cached_workspace = WorkspaceInfo.model_validate(cached_raw)
if workspace is None or _workspace_matches_identifier(cached_workspace, workspace):
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
return cached_workspace
workspaces = await get_available_workspaces(context=context)
if not workspaces:
raise ValueError(
"No accessible workspaces found for this account. "
"Ensure you have an active subscription and tenant access."
)
selected_workspace: WorkspaceInfo | None = None
if workspace:
matches = [item for item in workspaces if _workspace_matches_identifier(item, workspace)]
if not matches:
raise ValueError(
f"Workspace '{workspace}' was not found.\n"
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
if len(matches) > 1:
raise ValueError(
f"Workspace name '{workspace}' matches multiple workspaces. "
"Use tenant_id instead.\n"
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
selected_workspace = matches[0]
elif len(workspaces) == 1:
selected_workspace = workspaces[0]
else:
raise ValueError(
"Multiple workspaces are available. Ask the user which workspace to use, then retry "
"with the 'workspace' argument set to the tenant_id or unique name.\n"
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
if context:
await context.set_state("active_workspace", selected_workspace.model_dump())
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
return selected_workspace
async def get_active_project(
client: AsyncClient,
project: Optional[str] = None,
@@ -111,7 +252,7 @@ async def get_active_project(
project_names = await get_project_names(client, headers)
raise ValueError(
"No project specified. "
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
"Either set 'default_project' in config, or use 'project' argument.\n"
f"Available projects: {project_names}"
)
@@ -119,10 +260,12 @@ async def get_active_project(
# Check if already cached in context
if context:
cached_project = context.get_state("active_project")
if cached_project and cached_project.name == project:
logger.debug(f"Using cached project from context: {project}")
return cached_project
cached_raw = await context.get_state("active_project")
if isinstance(cached_raw, dict):
cached_project = ProjectItem.model_validate(cached_raw)
if cached_project.name == project:
logger.debug(f"Using cached project from context: {project}")
return cached_project
# Validate project exists by calling API
logger.debug(f"Validating project: {project}")
@@ -143,13 +286,103 @@ async def get_active_project(
# Cache in context if available
if context:
context.set_state("active_project", active_project)
await context.set_state("active_project", active_project.model_dump())
logger.debug(f"Cached project in context: {project}")
logger.debug(f"Validated project: {active_project.name}")
return active_project
def _split_project_prefix(path: str) -> tuple[Optional[str], str]:
"""Split a possible project prefix from a memory URL path."""
if "/" not in path:
return None, path
project_prefix, remainder = path.split("/", 1)
if not project_prefix or not remainder:
return None, path
if "*" in project_prefix:
return None, path
return project_prefix, remainder
async def resolve_project_and_path(
client: AsyncClient,
identifier: str,
project: Optional[str] = None,
context: Optional[Context] = None,
headers: HeaderTypes | None = None,
) -> tuple[ProjectItem, str, bool]:
"""Resolve project and normalized path for memory:// identifiers.
Returns:
Tuple of (active_project, normalized_path, is_memory_url)
"""
is_memory_url = identifier.strip().startswith("memory://")
if not is_memory_url:
active_project = await get_active_project(client, project, context, headers)
return active_project, identifier, False
normalized_path = normalize_project_reference(memory_url_path(identifier))
project_prefix, remainder = _split_project_prefix(normalized_path)
include_project = ConfigManager().config.permalinks_include_project
# Trigger: memory URL begins with a potential project segment
# 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:
try:
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
client,
"/v2/projects/resolve",
json={"identifier": project_prefix},
headers=headers,
)
resolved = ProjectResolveResponse.model_validate(response.json())
except ToolError as exc:
if "project not found" not in str(exc).lower():
raise
else:
resolved_project = await resolve_project_parameter(project_prefix)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
project_prefix
):
raise ValueError(
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
)
active_project = ProjectItem(
id=resolved.project_id,
external_id=resolved.external_id,
name=resolved.name,
path=resolved.path,
is_default=resolved.is_default,
)
if context:
await context.set_state("active_project", active_project.model_dump())
resolved_path = f"{resolved.permalink}/{remainder}" if include_project else remainder
return active_project, resolved_path, True
# Trigger: no resolvable project prefix in the memory URL
# Why: preserve existing memory URL behavior within the active project
# Outcome: use the active project and normalize the path for lookup
active_project = await get_active_project(client, project, context, headers)
resolved_path = normalized_path
if include_project:
# Trigger: project-prefixed permalinks are enabled and the path lacks a prefix
# Why: ensure memory URL lookups align with canonical permalinks
# Outcome: prefix the path with the active project's permalink
project_prefix = active_project.permalink
if resolved_path != project_prefix and not resolved_path.startswith(f"{project_prefix}/"):
resolved_path = f"{project_prefix}/{resolved_path}"
return active_project, resolved_path, True
def add_project_metadata(result: str, project_name: str) -> str:
"""Add project context as metadata footer for assistant session tracking.
@@ -164,3 +397,166 @@ def add_project_metadata(result: str, project_name: str) -> str:
Result with project session tracking metadata
"""
return f"{result}\n\n[Session: Using project '{project_name}']"
def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -> Optional[str]:
"""Check if a memory URL's first path segment matches a known project in config.
This enables automatic project routing from memory URLs like
``memory://specs/in-progress`` without requiring the caller to pass
an explicit ``project`` parameter.
Uses local config only — no network calls.
Args:
identifier: Raw identifier string (may or may not start with ``memory://``).
config: Current BasicMemoryConfig with project entries.
Returns:
Matching project name from config, or None if no match.
"""
path = memory_url_path(identifier) if identifier.strip().startswith("memory://") else identifier
normalized = normalize_project_reference(path)
prefix, _ = _split_project_prefix(normalized)
if prefix is None:
return None
prefix_permalink = generate_permalink(prefix)
for project_name in config.projects:
if generate_permalink(project_name) == prefix_permalink:
return project_name
return None
@asynccontextmanager
async def get_project_client(
project: Optional[str] = None,
workspace: Optional[str] = None,
context: Optional[Context] = None,
) -> AsyncIterator[Tuple[AsyncClient, ProjectItem]]:
"""Resolve project, create correctly-routed client, and validate project.
Solves the bootstrap problem: we need to know the project name to choose
the right client (local vs cloud), but we need the client to validate
the project. This helper resolves the project from config first (no
network), creates the correctly-routed client, then validates via API.
Routing decision order:
1. Explicit --local/--cloud flags → skip workspace, use flag routing
2. Cloud routing (explicit --cloud OR project mode CLOUD) →
resolve workspace via priority chain, create cloud client
3. Otherwise → local ASGI client
Workspace resolution priority (when cloud routing):
1. Explicit ``workspace`` parameter
2. Per-project ``workspace_id`` from config
3. Global ``default_workspace`` from config
4. MCP session cache (context)
5. Auto-select if single workspace
6. Error listing choices
Args:
project: Optional explicit project parameter
workspace: Optional cloud workspace selector (tenant_id or unique name)
context: Optional FastMCP context for caching
Yields:
Tuple of (client, active_project)
Raises:
ValueError: If no project can be resolved
RuntimeError: If cloud project but no API key configured
"""
# Deferred imports to avoid circular dependency
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
get_client,
is_factory_mode,
)
# Step 1: Resolve project name from config (no network call)
resolved_project = await resolve_project_parameter(project)
if not resolved_project:
# Fall back to local client to discover projects and raise helpful error
async with get_client() as client:
project_names = await get_project_names(client)
raise ValueError(
"No project specified. "
"Either set 'default_project' in config, or use 'project' argument.\n"
f"Available projects: {project_names}"
)
# Step 1b: Factory injection (in-process cloud server)
# Trigger: set_client_factory() was called (e.g., by cloud MCP server)
# Why: the transport layer already resolved workspace and tenant context;
# attempting cloud workspace resolution here would call the production
# control-plane API with no valid credentials and fail with 401
# Outcome: use the factory client directly, skip workspace resolution
if is_factory_mode():
async with get_client() as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
return
# Step 2: Check explicit routing BEFORE workspace resolution
# Trigger: CLI passed --local or --cloud
# Why: explicit flags must be deterministic — skip workspace entirely for --local
# Outcome: route strictly based on explicit flag, no workspace network calls
if _explicit_routing() and _force_local_mode():
async with get_client(project_name=resolved_project) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
return
# Step 3: Determine if cloud routing is needed
config = ConfigManager().config
project_entry = config.projects.get(resolved_project)
project_mode = config.get_project_mode(resolved_project)
# Trigger: workspace provided for a local project (without explicit --cloud)
# Why: workspace selection is a cloud routing concern only
# Outcome: fail fast with a deterministic guidance message
if project_mode != ProjectMode.CLOUD and workspace is not None and not _explicit_routing():
raise ValueError(
f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. "
"Workspace selection is only supported for cloud-mode projects."
)
if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()):
# --- Cloud routing: resolve workspace with priority chain ---
effective_workspace = workspace
# Priority 2: per-project workspace_id from config
if effective_workspace is None and project_entry and project_entry.workspace_id:
effective_workspace = project_entry.workspace_id
# Priority 3: global default_workspace from config
if effective_workspace is None and config.default_workspace:
effective_workspace = config.default_workspace
# Priorities 4-6: if still unresolved, fall back to resolve_workspace_parameter
# which checks context cache, auto-selects single workspace, or errors
if effective_workspace is not None:
# Config-resolved workspace — pass directly to get_client, skip network lookup
async with get_client(
project_name=resolved_project,
workspace=effective_workspace,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
else:
# No config-based workspace — use resolve_workspace_parameter for discovery
active_ws = await resolve_workspace_parameter(workspace=None, context=context)
async with get_client(
project_name=resolved_project,
workspace=active_ws.tenant_id,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
return
# Step 4: Local routing (default)
async with get_client(project_name=resolved_project) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
@@ -14,8 +14,8 @@ def ai_assistant_guide() -> str:
"""Return a concise guide on Basic Memory tools and how to use them.
Dynamically adapts instructions based on configuration:
- Default project mode: Simplified instructions with automatic project
- Regular mode: Project discovery and selection guidance
- Default project set: Simplified instructions with automatic project fallback
- No default project: Project discovery and selection guidance
- CLI constraint mode: Single project constraint information
Returns:
@@ -30,34 +30,32 @@ def ai_assistant_guide() -> str:
# Check configuration for mode-specific instructions
config = ConfigManager().config
# Add mode-specific header
mode_info = ""
if config.default_project_mode: # pragma: no cover
# Add mode-specific header based on whether a default project is configured
if config.default_project:
mode_info = f"""
# 🎯 Default Project Mode Active
# Default Project Active
**Current Configuration**: All operations automatically use project '{config.default_project}'
**Current Configuration**: Operations automatically fall back to project '{config.default_project}'
**Simplified Usage**: You don't need to specify the project parameter in tool calls.
- `write_note(title="Note", content="...", folder="docs")`
- Project parameter is optional and will default to '{config.default_project}'
- `write_note(title="Note", content="...", folder="docs")` - uses '{config.default_project}'
- To use a different project, explicitly specify: `project="other-project"`
────────────────────────────────────────
---
"""
else: # pragma: no cover
mode_info = """
# 🔧 Multi-Project Mode Active
# Multi-Project Mode
**Current Configuration**: Project parameter required for all operations
**Current Configuration**: No default project set — project parameter required for all operations
**Project Discovery Required**: Use these tools to select a project:
- `list_memory_projects()` - See all available projects
- `recent_activity()` - Get project activity and recommendations
- Remember the user's project choice throughout the conversation
────────────────────────────────────────
---
"""
@@ -65,6 +63,7 @@ def ai_assistant_guide() -> str:
enhanced_content = mode_info + content
logger.info(
f"Loaded AI assistant guide ({len(enhanced_content)} chars) with mode: {'default_project' if config.default_project_mode else 'multi_project'}"
f"Loaded AI assistant guide ({len(enhanced_content)} chars) "
f"with default_project: {config.default_project or 'none'}"
)
return enhanced_content
@@ -4,17 +4,15 @@ These prompts help users continue conversations and work across sessions,
providing context from previous interactions to maintain continuity.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.prompt import ContinueConversationRequest
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.search import search_notes
@mcp.prompt(
@@ -42,22 +40,92 @@ async def continue_conversation(
"""
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
async with get_client() as client:
config = ConfigManager().config
active_project = await get_active_project(client, project=config.default_project)
if topic:
# Use json format to get structured data for result counting and branching
result = await search_notes(query=topic, after_date=timeframe, output_format="json")
# Create request model
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
topic=topic, timeframe=timeframe
)
if isinstance(result, dict):
results = result.get("results", [])
context_text = _format_continuation_results(results, topic)
result_count = len(results)
else:
# Error string
context_text = str(result)
result_count = 0
else:
# No topic — show recent activity
effective_timeframe = timeframe or "7d"
activity_text = await recent_activity(timeframe=effective_timeframe)
context_text = str(activity_text)
result_count = -1 # Signals we used recent_activity
# Call the prompt API endpoint
response = await call_post(
client,
f"/v2/projects/{active_project.external_id}/prompt/continue-conversation",
json=request.model_dump(exclude_none=True),
)
target = f"'{topic}'" if topic else "recent activity"
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
prompt = dedent(f"""
# Continuing conversation on: {target}
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
{context_text}
---
## Next Steps
""")
if topic and result_count > 0:
prompt += dedent(f"""
Found {result_count} results related to '{topic}'.
1. **Read full content** - Use `read_note("permalink")` to dive into specific notes
2. **Build context** - Use `build_context("memory://path")` to see relationships
3. **Search deeper** - Use `search_notes("{topic}")` with different filters
> **Knowledge Capture:** As you continue this conversation, actively look for
> opportunities to record new information, decisions, or insights using `write_note()`.
""")
elif topic:
prompt += dedent(f"""
No previous context found for '{topic}'.
This is an opportunity to start documenting this topic:
1. **Create a new note** - Use `write_note(title="{topic}", content="...")` to start
2. **Search with variations** - Try `search_notes("{topic}")` with different terms
3. **Check recent activity** - Use `recent_activity(timeframe="7d")` to see what's new
""")
else:
prompt += dedent("""
1. **Explore specific items** - Use `read_note("permalink")` to dive deeper
2. **Search for topics** - Use `search_notes("topic")` to find specific content
3. **Build context** - Use `build_context("memory://path")` to see relationships
""")
return prompt
def _format_continuation_results(results: list[dict], topic: str) -> str:
"""Format search result dicts for conversation continuation context."""
if not results:
return f"No previous context found for '{topic}'."
lines = [f"## Previous Context for '{topic}'\n"]
for item in results:
title = item.get("title", "Untitled")
permalink = item.get("permalink", "")
lines.append(f"### {title}")
if permalink:
lines.append(f"permalink: {permalink}")
lines.append(f'Read with: `read_note("{permalink}")`')
content = item.get("content")
if content:
content = content[:300] + "..." if len(content) > 300 else content
lines.append(f"\n{content}")
lines.append("")
return "\n".join(lines)
@@ -46,7 +46,7 @@ async def recent_activity_prompt(
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
# Call the tool function - it returns a well-formatted string
activity_summary = await recent_activity.fn(project=project, timeframe=timeframe)
activity_summary = await recent_activity(project=project, timeframe=timeframe)
# Build the prompt response
# The tool already returns formatted markdown, so we use it directly
+56 -19
View File
@@ -3,17 +3,14 @@
These prompts help users search and explore their knowledge base.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.prompt import SearchPromptRequest
from basic_memory.mcp.tools.search import search_notes
@mcp.prompt(
@@ -41,20 +38,60 @@ async def search_prompt(
"""
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
async with get_client() as client:
config = ConfigManager().config
active_project = await get_active_project(client, project=config.default_project)
# Use json format to get structured data for result counting and formatting
result = await search_notes(query=query, after_date=timeframe, output_format="json")
# Create request model
request = SearchPromptRequest(query=query, timeframe=timeframe)
# Format the tool output into a prompt with guidance
if isinstance(result, dict):
results = result.get("results", [])
result_count = len(results)
result_text = _format_search_results(results, query)
else:
# Error string from search tool
result_count = 0
result_text = str(result)
# Call the prompt API endpoint
response = await call_post(
client,
f"/v2/projects/{active_project.external_id}/prompt/search",
json=request.model_dump(exclude_none=True),
)
return dedent(f"""
# Search Results: "{query}"
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
This is a memory retrieval session showing search results.
{result_text}
---
## Next Steps
Based on these {result_count} results, you can:
1. **Read a specific note** - Use `read_note("permalink")` to see full content
2. **Build context** - Use `build_context("memory://path")` to see relationships
3. **Refine search** - Use `search_notes("refined query")` to narrow results
4. **Check recent activity** - Use `recent_activity(timeframe="7d")` for recent changes
""")
def _format_search_results(results: list[dict], query: str) -> str:
"""Format search result dicts into readable markdown."""
if not results:
return f"No results found for '{query}'."
lines = [f"Found {len(results)} results:\n"]
for item in results:
title = item.get("title", "Untitled")
permalink = item.get("permalink", "")
score = item.get("score")
score_text = f" (score: {score:.2f})" if score else ""
lines.append(f"- **{title}**{score_text}")
if permalink:
lines.append(f" permalink: {permalink}")
content = item.get("content")
if content:
# Truncate content snippet
content = content[:200] + "..." if len(content) > 200 else content
lines.append(f" {content}")
lines.append("")
return "\n".join(lines)
@@ -0,0 +1,27 @@
"""MCP resources for Basic Memory."""
from basic_memory.mcp.resources.project_info import project_info
# TODO: re-enable once MCP client rendering is working
# from basic_memory.mcp.resources.ui import (
# note_preview_ui,
# note_preview_ui_mcp_ui,
# note_preview_ui_tool_ui,
# note_preview_ui_vanilla,
# search_results_ui,
# search_results_ui_mcp_ui,
# search_results_ui_tool_ui,
# search_results_ui_vanilla,
# )
__all__ = [
"project_info",
# "note_preview_ui",
# "note_preview_ui_mcp_ui",
# "note_preview_ui_tool_ui",
# "note_preview_ui_vanilla",
# "search_results_ui",
# "search_results_ui_mcp_ui",
# "search_results_ui_tool_ui",
# "search_results_ui_vanilla",
]
@@ -14,36 +14,30 @@ Basic Memory creates a semantic knowledge graph from markdown files. Focus on bu
**Your role**: You're helping humans build enduring knowledge they'll own forever. The semantic graph (observations, relations, context) helps you provide better assistance by understanding connections and maintaining continuity. Think: lasting insights worth keeping, not disposable chat logs.
## Project Management
## Project Management
All tools require explicit project specification.
**Three-tier resolution:**
1. CLI constraint: `--project name` (highest priority)
**Resolution priority:**
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
2. Explicit parameter: `project="name"` in tool calls
3. Default mode: `default_project_mode=true` in config (fallback)
3. Default project: `default_project` in config (fallback)
### Quick Setup Check
```python
# Discover projects
projects = await list_memory_projects()
# Check if default_project_mode enabled
# If yes: project parameter optional
# If no: project parameter required
```
### Default Project Mode
### Default Project
When `default_project_mode=true`:
When `default_project` is set in config:
```python
# These are equivalent:
await write_note("Note", "Content", "folder")
await write_note("Note", "Content", "folder", project="main")
```
When `default_project_mode=false` (default):
When no `default_project` is configured:
```python
# Project required:
await write_note("Note", "Content", "folder", project="main") # ✓
@@ -59,10 +53,20 @@ await write_note(
title="Topic",
content="# Topic\n## Observations\n- [category] fact\n## Relations\n- relates_to [[Other]]",
folder="notes",
project="main" # Required unless default_project_mode=true
project="main" # Optional if default_project is set in config
)
```
> **Important**: `write_note` errors if the note already exists. Use `edit_note` for incremental changes, or pass `overwrite=True` to replace.
```python
# Preferred: update an existing note incrementally
await edit_note(identifier="Topic", operation="append", content="\n- [category] new fact")
# Alternative: replace the entire note
await write_note(title="Topic", content="...", folder="notes", overwrite=True)
```
### Reading Knowledge
```python
@@ -76,11 +80,27 @@ content = await read_note("memory://folder/topic", project="main")
### Searching
```python
# Basic text search
results = await search_notes(query="authentication", project="main")
# Search types: "text" (default), "title", "permalink", "vector"/"semantic", "hybrid"
# Default is "hybrid" when semantic search is enabled, "text" otherwise
results = await search_notes(query="auth flow", search_type="hybrid")
# Tag shorthand in query (multiple tags: "tag:x AND tag:y" or "tag:x tag:y")
results = await search_notes(query="tag:security")
results = await search_notes(query="tag:coffee AND tag:brewing")
# Filter-only search (no query needed)
results = await search_notes(tags=["security", "auth"], status="active")
# Metadata filters with operators: $in, $gt, $gte, $lt, $lte, $between
results = await search_notes(
query="authentication",
project="main",
page_size=10
metadata_filters={"priority": {"$in": ["high", "critical"]}}
)
# Override similarity threshold for vector/hybrid search
results = await search_notes(query="auth", search_type="hybrid", min_similarity=0.5)
```
### Building Context
@@ -143,12 +163,11 @@ await write_note(
### 1. Project Management
**Single-project users:**
- Enable `default_project_mode=true`
- Simpler tool calls
- Set `default_project` in config (e.g., `"main"`)
- Simpler tool calls — project parameter is optional
**Multi-project users:**
- Keep `default_project_mode=false`
- Always specify project explicitly
- Always specify project explicitly in tool calls
**Discovery:**
```python
@@ -169,6 +188,8 @@ activity = await recent_activity(project="main")
- 2-3 relations per note
- Meaningful categories and relation types
**Prefer `edit_note` for updates** — use `write_note` only for new notes.
**Search before creating:**
```python
# Find existing entities to reference
@@ -200,7 +221,7 @@ Background information
**Missing project:**
```python
try:
await search_notes(query="test") # Missing project parameter - will error
await search_notes(query="test") # Fails if no default_project configured
except:
# Show available projects
projects = await list_memory_projects()
@@ -208,6 +229,14 @@ except:
results = await search_notes(query="test", project=projects[0].name)
```
**Note already exists:**
```python
# write_note returns an error if the note exists — use edit_note or overwrite
await edit_note(identifier="Existing Topic", operation="append", content="\n- [update] new info")
# Or replace entirely:
await write_note(title="Existing Topic", content="...", folder="notes", overwrite=True)
```
**Forward references:**
```python
# Check response for unresolved relations
@@ -263,13 +292,14 @@ context = await build_context(url=f"memory://{results[0].permalink}", project="m
| Tool | Purpose | Key Params |
|------|---------|------------|
| `write_note` | Create/update | title, content, folder, project |
| `write_note` | Create new | title, content, folder, project, overwrite |
| `read_note` | Read content | identifier, project |
| `edit_note` | Modify existing | identifier, operation, content, project |
| `search_notes` | Find notes | query, project |
| `search_notes` | Find notes | query, search_type, tags, metadata_filters, project |
| `build_context` | Graph traversal | url, depth, project |
| `recent_activity` | Recent changes | timeframe, project |
| `list_memory_projects` | Show projects | (none) |
| `list_workspaces` | Show workspaces | (none) |
## memory:// URL Format
@@ -277,6 +307,7 @@ context = await build_context(url=f"memory://{results[0].permalink}", project="m
- `memory://folder/title` - By folder + title
- `memory://permalink` - By permalink
- `memory://folder/*` - All in folder
- `memory://project-name/folder/title` - Cross-project (auto-routes to the correct project)
For full documentation: https://docs.basicmemory.com
@@ -0,0 +1,16 @@
# Basic Memory Cloud (optional)
Basic Memory Cloud is an optional add-on for users who want hosted access and sync.
- Hosted access to your knowledge
- Cross-device sync
- Mobile and web access
- Multi-client workflows (Claude, ChatGPT, Gemini, and others)
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
Get started:
```bash
bm cloud login
```
@@ -38,8 +38,8 @@ async def project_info(
Args:
project: Optional project name. If not provided, uses default_project
(if default_project_mode=true) or CLI constraint. If unknown,
use list_memory_projects() to discover available projects.
from config or CLI constraint. If unknown, use
list_memory_projects() to discover available projects.
context: Optional FastMCP context for performance caching.
Returns:
@@ -0,0 +1,16 @@
# Release Notes
## 2026-02-06
- Added optional cloud discovery copy to CLI first-run and promo-version notices.
- Added MCP tools for opt-in cloud discovery: `cloud_info` and `release_notes`.
- Updated docs and README copy to keep cloud messaging explicit and optional.
Cloud remains optional for open-source users.
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
Get started:
```bash
bm cloud login
```
+89
View File
@@ -0,0 +1,89 @@
"""UI resources for MCP Apps integration."""
from basic_memory.mcp.server import mcp
from basic_memory.mcp.ui import load_html, load_variant_html
# FastMCP's MIME type validator currently accepts only type/subtype, so we
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
MIME_TYPE = "text/html"
@mcp.resource(
uri="ui://basic-memory/search-results",
name="Basic Memory Search Results",
description="Search results UI for Basic Memory tools.",
mime_type=MIME_TYPE,
)
def search_results_ui() -> str:
return load_variant_html("search-results")
@mcp.resource(
uri="ui://basic-memory/note-preview",
name="Basic Memory Note Preview",
description="Note preview UI for Basic Memory read_note tool.",
mime_type=MIME_TYPE,
)
def note_preview_ui() -> str:
return load_variant_html("note-preview")
# Variant-specific resource URIs for bakeoff comparisons.
@mcp.resource(
uri="ui://basic-memory/search-results/vanilla",
name="Basic Memory Search Results (Vanilla)",
description="Vanilla HTML search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_vanilla() -> str:
return load_html("search-results-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/search-results/tool-ui",
name="Basic Memory Search Results (Tool UI)",
description="Tool UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_tool_ui() -> str:
return load_html("search-results-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/search-results/mcp-ui",
name="Basic Memory Search Results (MCP UI)",
description="MCP UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_mcp_ui() -> str:
return load_html("search-results-mcp-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/vanilla",
name="Basic Memory Note Preview (Vanilla)",
description="Vanilla HTML note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_vanilla() -> str:
return load_html("note-preview-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/tool-ui",
name="Basic Memory Note Preview (Tool UI)",
description="Tool UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_tool_ui() -> str:
return load_html("note-preview-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/mcp-ui",
name="Basic Memory Note Preview (MCP UI)",
description="MCP UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_mcp_ui() -> str:
return load_html("note-preview-mcp-ui.html")
+107 -1
View File
@@ -2,16 +2,71 @@
Basic Memory FastMCP server.
"""
import asyncio
import time
from contextlib import asynccontextmanager
from fastmcp import FastMCP
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from basic_memory import db
from basic_memory.cli.auth import CLIAuth
from basic_memory.config import BasicMemoryConfig
from basic_memory.db import (
scoped_session,
_needs_semantic_embedding_backfill,
_run_semantic_embedding_backfill,
)
from basic_memory.mcp.container import McpContainer, set_container
from basic_memory.services.initialization import initialize_app
async def _log_embedding_status(session_maker: async_sessionmaker[AsyncSession]) -> None:
"""Log a clear summary of semantic embedding status at startup."""
try:
async with scoped_session(session_maker) as session:
entity_count = (
await session.execute(text("SELECT COUNT(*) FROM entity"))
).scalar() or 0
chunk_count = (
await session.execute(text("SELECT COUNT(*) FROM search_vector_chunks"))
).scalar() or 0
embedding_count = (
await session.execute(text("SELECT COUNT(*) FROM search_vector_embeddings_rowids"))
).scalar() or 0
if entity_count == 0:
logger.info("Semantic embeddings: no entities yet")
elif embedding_count == 0:
logger.warning(
f"Semantic embeddings: EMPTY — {entity_count} entities have no embeddings. "
"Backfill running in background..."
)
else:
logger.info(
f"Semantic embeddings: {embedding_count} embeddings "
f"across {chunk_count} chunks for {entity_count} entities"
)
except Exception as exc:
logger.debug(f"Could not check embedding status at startup: {exc}")
async def _background_embedding_backfill(
config: BasicMemoryConfig,
session_maker: async_sessionmaker[AsyncSession],
) -> None:
"""Run semantic embedding backfill in the background without blocking startup."""
try:
if await _needs_semantic_embedding_backfill(config, session_maker):
logger.info("Background embedding backfill starting...")
await _run_semantic_embedding_backfill(config, session_maker)
await _log_embedding_status(session_maker)
except Exception as exc:
logger.error(f"Background embedding backfill failed: {exc}")
@asynccontextmanager
async def lifespan(app: FastMCP):
"""Lifecycle manager for the MCP server.
@@ -26,7 +81,39 @@ async def lifespan(app: FastMCP):
container = McpContainer.create()
set_container(container)
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
config = container.config
logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
logger.info(
f"Config: database_backend={config.database_backend.value}, "
f"semantic_search_enabled={config.semantic_search_enabled}, "
f"default_project={config.default_project}"
)
if config.semantic_search_enabled:
logger.info(
f"Semantic search: provider={config.semantic_embedding_provider}, "
f"model={config.semantic_embedding_model}, "
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
f"batch_size={config.semantic_embedding_batch_size}"
)
# Log configured projects with their routing mode
for name, entry in config.projects.items():
default = " (default)" if name == config.default_project else ""
logger.info(f"Project: {name} -> {entry.path} [mode={entry.mode.value}]{default}")
# Check cloud auth status (local file check, no network call)
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
tokens = auth.load_tokens()
if tokens is not None:
if not auth.is_token_valid(tokens):
expires_at = tokens.get("expires_at", 0)
expired_ago = int(time.time() - expires_at)
logger.warning(f"Cloud token expired {expired_ago}s ago - may need 'bm cloud login'")
else:
logger.info("Cloud: authenticated (OAuth token valid)")
if config.cloud_api_key:
logger.info("Cloud: API key configured")
# Track if we created the engine (vs test fixtures providing it)
# This prevents disposing an engine provided by test fixtures when
@@ -36,6 +123,16 @@ async def lifespan(app: FastMCP):
# Initialize app (runs migrations, reconciles projects)
await initialize_app(container.config)
# Log embedding status so it's easy to spot in the logs
backfill_task: asyncio.Task | None = None # type: ignore[type-arg]
if config.semantic_search_enabled and db._session_maker is not None:
await _log_embedding_status(db._session_maker)
# Launch backfill in background so MCP server is ready immediately
backfill_task = asyncio.create_task(
_background_embedding_backfill(config, db._session_maker),
name="embedding-backfill",
)
# Create and start sync coordinator (lifecycle centralized in coordinator)
sync_coordinator = container.create_sync_coordinator()
await sync_coordinator.start()
@@ -45,6 +142,15 @@ async def lifespan(app: FastMCP):
finally:
# Shutdown - coordinator handles clean task cancellation
logger.debug("Shutting down Basic Memory MCP server")
# Cancel embedding backfill if still running
if backfill_task is not None and not backfill_task.done():
backfill_task.cancel()
try:
await backfill_task
except asyncio.CancelledError:
logger.info("Background embedding backfill cancelled during shutdown")
await sync_coordinator.stop()
# Only shutdown DB if we created it (not if test fixture provided it)
+18 -2
View File
@@ -11,13 +11,19 @@ from basic_memory.mcp.tools.read_content import read_content
from basic_memory.mcp.tools.build_context import build_context
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.read_note import read_note
# TODO: re-enable once MCP client rendering is working
# from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
from basic_memory.mcp.tools.view_note import view_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.search import search_notes, search_by_metadata
from basic_memory.mcp.tools.cloud_info import cloud_info
from basic_memory.mcp.tools.release_notes import release_notes
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.workspaces import list_workspaces
from basic_memory.mcp.tools.project_management import (
list_memory_projects,
create_memory_project,
@@ -27,9 +33,13 @@ from basic_memory.mcp.tools.project_management import (
# ChatGPT-compatible tools
from basic_memory.mcp.tools.chatgpt_tools import search, fetch
# Schema tools
from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff
__all__ = [
"build_context",
"canvas",
"cloud_info",
"create_memory_project",
"delete_note",
"delete_project",
@@ -37,13 +47,19 @@ __all__ = [
"fetch",
"list_directory",
"list_memory_projects",
"list_workspaces",
"move_note",
"read_content",
"read_note",
"release_notes",
# "read_note_ui",
"recent_activity",
"schema_diff",
"schema_infer",
"schema_validate",
"search",
"search_by_metadata",
"search_notes",
# "search_notes_ui",
"view_note",
"write_note",
]
+130 -21
View File
@@ -1,21 +1,114 @@
"""Build context tool for Basic Memory MCP server."""
from typing import Optional
from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
get_project_client,
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
ContextResult,
EntitySummary,
GraphContext,
MemoryUrl,
memory_url_path,
ObservationSummary,
RelationSummary,
)
def _format_entity_block(result: ContextResult) -> str:
"""Format a single context result as a markdown block."""
primary = result.primary_result
lines = []
# --- Header ---
lines.append(f"## {primary.title}")
if primary.permalink:
lines.append(f"permalink: {primary.permalink}")
# RelationSummary has no content field; Entity/Observation do
if not isinstance(primary, RelationSummary) and primary.content:
lines.append("")
lines.append(primary.content)
# --- Observations ---
if result.observations:
lines.append("")
lines.append("### Observations")
for obs in result.observations:
lines.append(f"- [{obs.category}] {obs.content}")
# --- Relations (from primary's related_results that are RelationSummary) ---
relation_items: list[RelationSummary] = [
r for r in result.related_results if isinstance(r, RelationSummary)
]
if relation_items:
lines.append("")
lines.append("### Relations")
for rel in relation_items:
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
# --- Related entities (non-relation related results) ---
related_entities: list[EntitySummary | ObservationSummary] = [
r for r in result.related_results if not isinstance(r, RelationSummary)
]
if related_entities:
lines.append("")
lines.append("### Related")
for item in related_entities:
permalink = item.permalink if item.permalink else ""
lines.append(f"- [[{item.title}]] ({permalink})")
return "\n".join(lines)
def _format_context_markdown(graph: GraphContext, project: str) -> str:
"""Format GraphContext as compact markdown text.
Produces a human-readable markdown representation that is much smaller
than the equivalent JSON, suitable for LLM consumption when structured
data isn't needed.
"""
if not graph.results:
uri = graph.metadata.uri or ""
return f"No results found for '{uri}' in project '{project}'."
parts = []
# --- Title from first primary result ---
first_title = graph.results[0].primary_result.title
if len(graph.results) == 1:
parts.append(f"# Context: {first_title}")
else:
uri = graph.metadata.uri or ""
parts.append(f"# Context: {uri}")
parts.append("")
# --- Entity blocks separated by --- ---
entity_blocks = [_format_entity_block(result) for result in graph.results]
parts.append("\n\n---\n\n".join(entity_blocks))
# --- Footer ---
meta = graph.metadata
primary_count = meta.primary_count or 0
related_count = meta.related_count or 0
parts.append("")
parts.append("---")
parts.append(
f"*{primary_count} primary, {related_count} related"
f" | depth={meta.depth} | project: {project}*"
)
return "\n".join(parts)
@mcp.tool(
description="""Build context from a memory:// URI to continue conversations naturally.
@@ -31,18 +124,25 @@ from basic_memory.schemas.memory import (
Timeframes support natural language like:
- "2 days ago", "last week", "today", "3 months ago"
- Or standard formats like "7d", "24h"
Format options:
- "json" (default): Structured JSON with internal fields excluded
- "text": Compact markdown text for LLM consumption
""",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
url: MemoryUrl,
project: Optional[str] = None,
workspace: Optional[str] = None,
depth: str | int | None = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> GraphContext:
) -> dict | str:
"""Get context needed to continue a discussion within a specific project.
This tool enables natural continuation of discussions by loading relevant context
@@ -50,8 +150,9 @@ async def build_context(
a rich context graph of related information.
Project Resolution:
Server resolves projects in this order: Single Project Mode → project parameter → default project.
If project unknown, use list_memory_projects() or recent_activity() first.
Server resolves projects using a unified priority chain (same in local and cloud modes):
Single Project Mode → project parameter → default project.
Uses default project automatically. Specify `project` parameter to target a different project.
Args:
project: Project name to build context from. Optional - server will resolve using hierarchy.
@@ -62,13 +163,13 @@ async def build_context(
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
output_format: Response format - "json" for structured JSON dict,
"text" for compact markdown text
context: Optional FastMCP context for performance caching.
Returns:
GraphContext containing:
- primary_results: Content matching the memory:// URI
- related_results: Connected content via relations
- metadata: Context building details
dict (output_format="json"): Structured JSON with internal fields excluded
str (output_format="text"): Compact markdown representation
Examples:
# Continue a specific discussion
@@ -77,15 +178,18 @@ async def build_context(
# Get deeper context about a component
build_context("work-docs", "memory://components/memory-service", depth=2)
# Look at recent changes to a specification
build_context("research", "memory://specs/document-format", timeframe="today")
# Research the history of a feature
build_context("dev-notes", "memory://features/knowledge-graph", timeframe="3 months ago")
# Get text output for compact context
build_context("research", "memory://specs/search", output_format="text")
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(url, ConfigManager().config)
if detected:
project = detected
logger.info(f"Building context from {url} in project {project}")
# Convert string depth to integer if needed
@@ -99,20 +203,25 @@ async def build_context(
# URL is already validated and normalized by MemoryUrl type annotation
async with get_client() as client:
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)
async with get_project_client(project, workspace, context) as (client, active_project):
# Resolve memory:// identifier with project-prefix awareness
_, resolved_path, _ = await resolve_project_and_path(client, url, project, context)
# Import here to avoid circular import
from basic_memory.mcp.clients import MemoryClient
# Use typed MemoryClient for API calls
memory_client = MemoryClient(client, active_project.external_id)
return await memory_client.build_context(
memory_url_path(url),
graph = await memory_client.build_context(
resolved_path,
depth=depth or 1,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
if output_format == "text":
return _format_context_markdown(graph, active_project.name)
return graph.model_dump()
+9 -8
View File
@@ -4,26 +4,29 @@ This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec
"""
import json
from typing import Dict, List, Any, Optional
from typing import Annotated, Dict, List, Any, Optional
from loguru import logger
from fastmcp import Context
from pydantic import BeforeValidator
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.utils import coerce_list
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
)
async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
title: str,
directory: str,
project: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -94,9 +97,7 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, workspace, context) as (client, active_project):
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{directory}/{file_title}"

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