Compare commits

..

17 Commits

Author SHA1 Message Date
claude[bot] 1b95bfa801 fix: show actual MCP transport type in project list column
Rename 'MCP (stdio)' column to 'MCP' and update values to reflect the
actual transport used (stdio for local projects, http for cloud-routed
projects) instead of whether a local DB entry exists.

Closes #659

Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
2026-03-10 17:37:16 +00:00
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
66 changed files with 2018 additions and 2409 deletions
+159 -4
View File
@@ -2,13 +2,168 @@
## 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`
- Default behavior is unchanged: `content` still includes raw markdown with frontmatter.
- With `--strip-frontmatter`, both text and JSON modes return body-only markdown content.
- JSON output now includes an additive `frontmatter` field with parsed YAML metadata (or `null`
when no valid opening frontmatter block exists).
- 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)
+12
View File
@@ -23,6 +23,18 @@ 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.
## 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)
-18
View File
@@ -69,24 +69,6 @@ testmon *args:
test-smoke:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
# Run graph intelligence API contract tests only
test-graph-intel-api:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/api/v2/test_graph_intelligence_router.py
# Run graph intelligence MCP tests only
test-graph-intel-mcp:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/mcp/clients/test_graph_clients.py tests/mcp/test_tool_graph_intelligence.py tests/mcp/test_tool_contracts.py
# Run graph intelligence CLI passthrough tests only
test-graph-intel-cli:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/cli/test_cli_tool_graph_intelligence_json_output.py
# Run the full graph intelligence fast iteration slice
test-graph-intel:
just test-graph-intel-api
just test-graph-intel-mcp
just test-graph-intel-cli
# Fast local loop: lint, format, typecheck, impacted tests
fast-check:
just fix
+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"
-4
View File
@@ -19,8 +19,6 @@ from basic_memory.api.v2.routers import (
prompt_router as v2_prompt,
importer_router as v2_importer,
schema_router as v2_schema,
graph_router as v2_graph,
fcm_router as v2_fcm,
)
from basic_memory.api.v2.routers.project_router import (
add_project,
@@ -88,8 +86,6 @@ 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_graph, prefix="/v2/projects/{project_id}")
app.include_router(v2_fcm, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")
# Legacy web app proxy paths (compat with /proxy/projects/projects)
-4
View File
@@ -21,8 +21,6 @@ from basic_memory.api.v2.routers import (
directory_router,
prompt_router,
importer_router,
graph_router,
fcm_router,
)
__all__ = [
@@ -34,6 +32,4 @@ __all__ = [
"directory_router",
"prompt_router",
"importer_router",
"graph_router",
"fcm_router",
]
@@ -9,8 +9,6 @@ from basic_memory.api.v2.routers.directory_router import router as directory_rou
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
from basic_memory.api.v2.routers.graph_router import router as graph_router
from basic_memory.api.v2.routers.fcm_router import router as fcm_router
__all__ = [
"knowledge_router",
@@ -22,6 +20,4 @@ __all__ = [
"prompt_router",
"importer_router",
"schema_router",
"graph_router",
"fcm_router",
]
@@ -1,61 +0,0 @@
"""V2 router for FCM simulation and interop endpoints."""
from fastapi import APIRouter
from basic_memory.deps import FCMServiceV2ExternalDep, ProjectExternalIdPathDep
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMExportResponse,
FCMImportRequest,
FCMImportResponse,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMSimulateRequest,
FCMSimulateResponse,
)
router = APIRouter(prefix="/fcm", tags=["fcm-v2"])
@router.post("/simulate", response_model=FCMSimulateResponse)
async def fcm_simulate(
request: FCMSimulateRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMSimulateResponse:
"""Run an FCM scenario simulation."""
_ = project_id
return await fcm_service.simulate(request)
@router.post("/rank-actions", response_model=FCMRankActionsResponse)
async def fcm_rank_actions(
request: FCMRankActionsRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMRankActionsResponse:
"""Rank action candidates toward a goal."""
_ = project_id
return await fcm_service.rank_actions(request)
@router.post("/import", response_model=FCMImportResponse)
async def fcm_import(
request: FCMImportRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMImportResponse:
"""Import an FCM model using a supported interchange format."""
_ = project_id
return await fcm_service.import_model(request)
@router.post("/export", response_model=FCMExportResponse)
async def fcm_export(
request: FCMExportRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMExportResponse:
"""Export an FCM model using a supported interchange format."""
_ = project_id
return await fcm_service.export_model(request)
@@ -1,71 +0,0 @@
"""V2 router for graph intelligence endpoints."""
from fastapi import APIRouter, Query
from basic_memory.deps import (
GraphIntelligenceServiceV2ExternalDep,
ProjectExternalIdPathDep,
TaskSchedulerDep,
)
from basic_memory.schemas.graph_intelligence import (
GraphHealthResponse,
GraphImpactRequest,
GraphImpactResponse,
GraphLineageRequest,
GraphLineageResponse,
GraphReindexRequest,
GraphReindexResponse,
)
router = APIRouter(prefix="/graph", tags=["graph-v2"])
@router.post("/lineage", response_model=GraphLineageResponse)
async def graph_lineage(
request: GraphLineageRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> GraphLineageResponse:
"""Build lineage paths from a start node toward an optional goal."""
_ = project_id
return await graph_service.lineage(request)
@router.post("/impact", response_model=GraphImpactResponse)
async def graph_impact(
request: GraphImpactRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> GraphImpactResponse:
"""Compute impact radius from a target node."""
_ = project_id
return await graph_service.impact(request)
@router.get("/health", response_model=GraphHealthResponse)
async def graph_health(
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
scope: str | None = Query(default=None),
timeframe: str | None = Query(default=None),
) -> GraphHealthResponse:
"""Report graph quality metrics and issue candidates."""
_ = project_id
return await graph_service.health(scope=scope, timeframe=timeframe)
@router.post("/reindex", response_model=GraphReindexResponse)
async def graph_reindex(
request: GraphReindexRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
task_scheduler: TaskSchedulerDep,
project_id: ProjectExternalIdPathDep,
) -> GraphReindexResponse:
"""Queue a graph reindex operation for the current project."""
task_scheduler.schedule(
"reindex_graph_project",
project_id=project_id,
mode=request.mode,
reason=request.reason,
)
return await graph_service.start_reindex_job()
@@ -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,
@@ -56,6 +60,50 @@ def _schedule_vector_sync_if_enabled(
)
## 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
+13 -2
View File
@@ -128,7 +128,7 @@ def list_projects(
table.add_column("Cloud Path", style="green")
table.add_column("Workspace", style="green")
table.add_column("CLI Route", style="blue")
table.add_column("MCP (stdio)", style="blue")
table.add_column("MCP", style="blue")
table.add_column("Sync", style="green")
table.add_column("Default", style="magenta")
@@ -182,7 +182,18 @@ def list_projects(
is_default = config.default_project == project_name
has_sync = bool(entry and entry.local_sync_path)
mcp_stdio_target = "local" if local_project is not None else "n/a"
# Determine MCP transport type based on project routing mode.
# Trigger: entry.mode or cloud-only project presence
# Why: the original "local"/"n/a" only reflected local DB presence, not transport
# Outcome: column now shows the actual MCP transport (stdio vs http)
if entry and entry.mode == ProjectMode.CLOUD:
mcp_stdio_target = "http"
elif cloud_project is not None and local_project is None and entry is None:
# Cloud-only project with no local config entry
mcp_stdio_target = "http"
else:
mcp_stdio_target = "stdio"
# Show workspace name (type) for cloud-sourced projects
ws_label = ""
-384
View File
@@ -16,13 +16,6 @@ from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import fcm_export_model as mcp_fcm_export_model
from basic_memory.mcp.tools import fcm_import_model as mcp_fcm_import_model
from basic_memory.mcp.tools import fcm_rank_actions as mcp_fcm_rank_actions
from basic_memory.mcp.tools import fcm_simulate as mcp_fcm_simulate
from basic_memory.mcp.tools import graph_health as mcp_graph_health
from basic_memory.mcp.tools import graph_impact as mcp_graph_impact
from basic_memory.mcp.tools import graph_lineage as mcp_graph_lineage
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
from basic_memory.mcp.tools import read_note as mcp_read_note
@@ -47,17 +40,6 @@ def _print_json(result: Any) -> None:
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
def _parse_json_option(raw_value: Optional[str], option_name: str) -> Any:
"""Parse a JSON CLI option with deterministic error handling."""
if raw_value is None:
return None
try:
return json.loads(raw_value)
except json.JSONDecodeError as exc:
typer.echo(f"Invalid JSON for {option_name}: {exc}", err=True)
raise typer.Exit(1)
# --- Commands ---
@@ -384,372 +366,6 @@ def recent_activity(
raise
@tool_app.command("graph-lineage")
def graph_lineage(
start: Annotated[str, typer.Argument(help="Start node identifier or memory:// reference")],
goal: Annotated[
Optional[str],
typer.Option("--goal", help="Optional goal node identifier for targeted lineage"),
] = None,
max_hops: int = typer.Option(4, "--max-hops", help="Maximum traversal hops (1-6)"),
relation_filters: Annotated[
Optional[List[str]],
typer.Option("--relation-filter", help="Relation filters (repeatable)"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get graph lineage paths from a start node."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_lineage(
start=start,
goal=goal,
max_hops=max_hops,
relation_filters=relation_filters or [],
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_lineage: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("graph-impact")
def graph_impact(
target: Annotated[str, typer.Argument(help="Target node identifier or memory:// reference")],
horizon: int = typer.Option(2, "--horizon", help="Impact horizon in hops (1-4)"),
relation_filters: Annotated[
Optional[List[str]],
typer.Option("--relation-filter", help="Relation filters (repeatable)"),
] = None,
include_reasons: bool = typer.Option(
True,
"--include-reasons/--no-include-reasons",
help="Include reason strings in impact output",
),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get impact radius for a target node."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_impact(
target=target,
horizon=horizon,
relation_filters=relation_filters or [],
include_reasons=include_reasons,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_impact: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("graph-health")
def graph_health(
scope: Annotated[Optional[str], typer.Option("--scope", help="Optional scope prefix")] = None,
timeframe: Annotated[
Optional[str], typer.Option("--timeframe", help="Optional timeframe filter")
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get graph health metrics and issue candidates."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_health(
scope=scope,
timeframe=timeframe,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_health: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-simulate")
def fcm_simulate(
actions_json: Annotated[
str,
typer.Option(
"--actions-json",
help='JSON array of actions, e.g. [{"node_id":"n1","delta":0.2}]',
),
],
scenario_json: Annotated[
Optional[str],
typer.Option("--scenario-json", help="Optional JSON scenario object"),
] = None,
clamp_rules_json: Annotated[
Optional[str],
typer.Option("--clamp-rules-json", help="Optional JSON array of clamp rules"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Run an FCM simulation."""
actions = _parse_json_option(actions_json, "--actions-json")
scenario = _parse_json_option(scenario_json, "--scenario-json")
clamp_rules = _parse_json_option(clamp_rules_json, "--clamp-rules-json")
if not isinstance(actions, list):
typer.echo("Invalid JSON for --actions-json: expected a JSON array", err=True)
raise typer.Exit(1)
if scenario is not None and not isinstance(scenario, dict):
typer.echo("Invalid JSON for --scenario-json: expected a JSON object", err=True)
raise typer.Exit(1)
if clamp_rules is not None and not isinstance(clamp_rules, list):
typer.echo("Invalid JSON for --clamp-rules-json: expected a JSON array", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_simulate(
actions=actions,
scenario=scenario,
clamp_rules=clamp_rules,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_simulate: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-rank-actions")
def fcm_rank_actions(
goal: Annotated[str, typer.Argument(help="Goal node identifier")],
constraints_json: Annotated[
Optional[str],
typer.Option("--constraints-json", help="Optional JSON object of ranking constraints"),
] = None,
top_k: int = typer.Option(10, "--top-k", help="Number of recommendations to return"),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Rank intervention actions for an FCM goal."""
constraints = _parse_json_option(constraints_json, "--constraints-json")
if constraints is not None and not isinstance(constraints, dict):
typer.echo("Invalid JSON for --constraints-json: expected a JSON object", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_rank_actions(
goal=goal,
constraints=constraints,
top_k=top_k,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_rank_actions: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-import-model")
def fcm_import_model(
source: Annotated[str, typer.Argument(help="Source path or URI for import payload")],
format: Annotated[
str,
typer.Option("--format", help="Import format (currently csv_bundle_v1)"),
] = "csv_bundle_v1",
merge_mode: Annotated[
str,
typer.Option("--merge-mode", help="Merge strategy: replace or upsert"),
] = "upsert",
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Import an FCM model."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_import_model(
source=source,
format=format, # pyright: ignore[reportArgumentType]
merge_mode=merge_mode, # pyright: ignore[reportArgumentType]
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_import_model: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-export-model")
def fcm_export_model(
format: Annotated[
str,
typer.Option("--format", help="Export format (currently csv_bundle_v1)"),
] = "csv_bundle_v1",
selection_json: Annotated[
Optional[str],
typer.Option("--selection-json", help="Optional JSON object selection payload"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Export an FCM model."""
selection = _parse_json_option(selection_json, "--selection-json")
if selection is not None and not isinstance(selection, dict):
typer.echo("Invalid JSON for --selection-json: expected a JSON object", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_export_model(
format=format, # pyright: ignore[reportArgumentType]
selection=selection,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_export_model: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("search-notes")
def search_notes(
query: Annotated[
+1 -1
View File
@@ -12,7 +12,7 @@ from basic_memory.config import ConfigManager
OSS_DISCOUNT_CODE = "BMFOSS"
CLOUD_LEARN_MORE_URL = (
"https://basicmemory.com?utm_source=bm-cli&utm_medium=promo&utm_campaign=cloud-upsell"
"https://basicmemory.com?utm_source=bm-foss&utm_medium=promo&utm_campaign=cloud-upsell"
)
+34 -52
View File
@@ -43,40 +43,37 @@ if sys.platform == "win32": # pragma: no cover
_engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
# Alembic revision that enables one-time automatic embedding backfill.
SEMANTIC_EMBEDDING_BACKFILL_REVISION = "i2c3d4e5f6g7"
async def _load_applied_alembic_revisions(
async def _needs_semantic_embedding_backfill(
app_config: BasicMemoryConfig,
session_maker: async_sessionmaker[AsyncSession],
) -> set[str]:
"""Load applied Alembic revisions from alembic_version.
) -> bool:
"""Check if entities exist but vector embeddings are empty.
Returns an empty set when the version table does not exist yet
(fresh database before first migration).
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:
result = await session.execute(text("SELECT version_num FROM alembic_version"))
return {str(row[0]) for row in result.fetchall() if row[0]}
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:
error_message = str(exc).lower()
if "alembic_version" in error_message and (
"no such table" in error_message or "does not exist" in error_message
):
return set()
raise
def _should_run_semantic_embedding_backfill(
revisions_before_upgrade: set[str],
revisions_after_upgrade: set[str],
) -> bool:
"""Check if this migration run newly applied the backfill-trigger revision."""
return (
SEMANTIC_EMBEDDING_BACKFILL_REVISION in revisions_after_upgrade
and SEMANTIC_EMBEDDING_BACKFILL_REVISION not in revisions_before_upgrade
)
# Table might not exist yet (pre-migration)
logger.debug(f"Could not check embedding status: {exc}")
return False
async def _run_semantic_embedding_backfill(
@@ -480,26 +477,9 @@ async def run_migrations(
Note: Alembic tracks which migrations have been applied via the alembic_version table,
so it's safe to call this multiple times - it will only run pending migrations.
"""
logger.debug("Running database migrations...")
logger.info("Running database migrations...")
temp_engine: AsyncEngine | None = None
try:
revisions_before_upgrade: set[str] = set()
# Trigger: run_migrations() can be invoked before module-level session maker is set.
# Why: we still need reliable before/after revision detection for one-time backfill.
# Outcome: create a short-lived session maker when needed, then dispose it immediately.
if _session_maker is None:
precheck_engine, temp_session_maker = _create_engine_and_session(
app_config.database_path,
database_type,
app_config,
)
try:
revisions_before_upgrade = await _load_applied_alembic_revisions(temp_session_maker)
finally:
await precheck_engine.dispose()
else:
revisions_before_upgrade = await _load_applied_alembic_revisions(_session_maker)
# Get the absolute path to the alembic directory relative to this file
alembic_dir = Path(__file__).parent / "alembic"
config = Config()
@@ -519,7 +499,7 @@ async def run_migrations(
config.set_main_option("sqlalchemy.url", db_url)
command.upgrade(config, "head")
logger.debug("Migrations completed successfully")
logger.info("Migrations completed successfully")
# Get session maker - ensure we don't trigger recursive migration calls
if _session_maker is None:
@@ -541,12 +521,14 @@ async def run_migrations(
else:
await SQLiteSearchRepository(session_maker, 1).init_search_index()
revisions_after_upgrade = await _load_applied_alembic_revisions(session_maker)
if _should_run_semantic_embedding_backfill(
revisions_before_upgrade,
revisions_after_upgrade,
):
await _run_semantic_embedding_backfill(app_config, session_maker)
# 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
-8
View File
@@ -131,10 +131,6 @@ from basic_memory.deps.services import (
DirectoryServiceV2Dep,
get_directory_service_v2_external,
DirectoryServiceV2ExternalDep,
get_graph_intelligence_service_v2_external,
GraphIntelligenceServiceV2ExternalDep,
get_fcm_service_v2_external,
FCMServiceV2ExternalDep,
)
from basic_memory.deps.importers import (
@@ -273,10 +269,6 @@ __all__ = [
"DirectoryServiceV2Dep",
"get_directory_service_v2_external",
"DirectoryServiceV2ExternalDep",
"get_graph_intelligence_service_v2_external",
"GraphIntelligenceServiceV2ExternalDep",
"get_fcm_service_v2_external",
"FCMServiceV2ExternalDep",
# Importers
"get_chatgpt_importer",
"ChatGPTImporterDep",
-44
View File
@@ -39,8 +39,6 @@ from basic_memory.deps.repositories import (
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.services import EntityService, ProjectService
from basic_memory.services.fcm_service import FCMService
from basic_memory.services.graph_intelligence_service import GraphIntelligenceService
from basic_memory.services.context_service import ContextService
from basic_memory.services.directory_service import DirectoryService
from basic_memory.services.file_service import FileService
@@ -360,30 +358,6 @@ async def get_context_service_v2_external(
ContextServiceV2ExternalDep = Annotated[ContextService, Depends(get_context_service_v2_external)]
# --- Graph Intelligence Service ---
async def get_graph_intelligence_service_v2_external() -> GraphIntelligenceService:
"""Create GraphIntelligenceService for v2 API (uses external_id routing)."""
return GraphIntelligenceService()
GraphIntelligenceServiceV2ExternalDep = Annotated[
GraphIntelligenceService, Depends(get_graph_intelligence_service_v2_external)
]
# --- FCM Service ---
async def get_fcm_service_v2_external() -> FCMService:
"""Create FCMService for v2 API (uses external_id routing)."""
return FCMService()
FCMServiceV2ExternalDep = Annotated[FCMService, Depends(get_fcm_service_v2_external)]
# --- Sync Service ---
@@ -561,21 +535,6 @@ async def get_task_scheduler(
async def _reindex_project(**_: Any) -> None:
await search_service.reindex_all()
async def _sync_graph_entity(entity_id: int, **extra_payload: Any) -> None:
# Trigger: graph-entity sync task is scheduled from graph lifecycle hooks.
# Why: keep scheduler contract stable while graph index provider work lands in later phases.
# Outcome: no-op in phase 1; task name remains valid for API and tool contracts.
del entity_id, extra_payload
async def _sync_graph_project(force_full: bool = False, **_: Any) -> None:
await _sync_project(force_full=force_full)
async def _reindex_graph_project(**_: Any) -> None:
# Trigger: graph reindex requested.
# Why: phase 1 has no dedicated graph index worker yet.
# Outcome: run project sync path so writes stay coherent while graph provider ships.
await _sync_project(force_full=True)
scheduler = LocalTaskScheduler(
{
"reindex_entity": _reindex_entity,
@@ -583,9 +542,6 @@ async def get_task_scheduler(
"sync_entity_vectors": _sync_entity_vectors,
"sync_project": _sync_project,
"reindex_project": _reindex_project,
"sync_graph_entity": _sync_graph_entity,
"sync_graph_project": _sync_graph_project,
"reindex_graph_project": _reindex_graph_project,
},
test_mode=app_config.is_test_env,
)
-4
View File
@@ -18,8 +18,6 @@ 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
from basic_memory.mcp.clients.graph import GraphClient
from basic_memory.mcp.clients.fcm import FCMClient
__all__ = [
"KnowledgeClient",
@@ -29,6 +27,4 @@ __all__ = [
"ResourceClient",
"ProjectClient",
"SchemaClient",
"GraphClient",
"FCMClient",
]
-56
View File
@@ -1,56 +0,0 @@
"""Typed client for FCM API operations."""
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMExportResponse,
FCMImportRequest,
FCMImportResponse,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMSimulateRequest,
FCMSimulateResponse,
)
class FCMClient:
"""Typed client for FCM operations."""
def __init__(self, http_client: AsyncClient, project_id: str):
self.http_client = http_client
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/fcm"
async def simulate(self, request: FCMSimulateRequest) -> FCMSimulateResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/simulate",
json=request.model_dump(mode="json"),
)
return FCMSimulateResponse.model_validate(response.json())
async def rank_actions(self, request: FCMRankActionsRequest) -> FCMRankActionsResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/rank-actions",
json=request.model_dump(mode="json"),
)
return FCMRankActionsResponse.model_validate(response.json())
async def import_model(self, request: FCMImportRequest) -> FCMImportResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/import",
json=request.model_dump(mode="json"),
)
return FCMImportResponse.model_validate(response.json())
async def export_model(self, request: FCMExportRequest) -> FCMExportResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/export",
json=request.model_dump(mode="json"),
)
return FCMExportResponse.model_validate(response.json())
-62
View File
@@ -1,62 +0,0 @@
"""Typed client for graph intelligence API operations."""
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.schemas.graph_intelligence import (
GraphHealthResponse,
GraphImpactRequest,
GraphImpactResponse,
GraphLineageRequest,
GraphLineageResponse,
GraphReindexRequest,
GraphReindexResponse,
)
class GraphClient:
"""Typed client for graph intelligence operations."""
def __init__(self, http_client: AsyncClient, project_id: str):
self.http_client = http_client
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/graph"
async def lineage(self, request: GraphLineageRequest) -> GraphLineageResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/lineage",
json=request.model_dump(mode="json"),
)
return GraphLineageResponse.model_validate(response.json())
async def impact(self, request: GraphImpactRequest) -> GraphImpactResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/impact",
json=request.model_dump(mode="json"),
)
return GraphImpactResponse.model_validate(response.json())
async def health(
self, scope: str | None = None, timeframe: str | None = None
) -> GraphHealthResponse:
params: dict[str, str] = {}
if scope is not None:
params["scope"] = scope
if timeframe is not None:
params["timeframe"] = timeframe
response = await call_get(
self.http_client,
f"{self._base_path}/health",
params=params,
)
return GraphHealthResponse.model_validate(response.json())
async def reindex(self, request: GraphReindexRequest) -> GraphReindexResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/reindex",
json=request.model_dump(mode="json"),
)
return GraphReindexResponse.model_validate(response.json())
+72
View File
@@ -2,18 +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.
@@ -70,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()
@@ -79,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
View File
@@ -24,16 +24,6 @@ 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.graph_intelligence import (
graph_lineage,
graph_impact,
graph_health,
graph_reindex,
fcm_simulate,
fcm_rank_actions,
fcm_import_model,
fcm_export_model,
)
from basic_memory.mcp.tools.project_management import (
list_memory_projects,
create_memory_project,
@@ -54,15 +44,7 @@ __all__ = [
"delete_note",
"delete_project",
"edit_note",
"fcm_export_model",
"fcm_import_model",
"fcm_rank_actions",
"fcm_simulate",
"fetch",
"graph_health",
"graph_impact",
"graph_lineage",
"graph_reindex",
"list_directory",
"list_memory_projects",
"list_workspaces",
+5 -3
View File
@@ -4,12 +4,14 @@ 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.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
@@ -19,8 +21,8 @@ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
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,
+1 -1
View File
@@ -318,7 +318,7 @@ delete_note("path/to/file.md")
note_file_path = None
try:
# Resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(identifier)
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
if output_format == "json":
entity = await knowledge_client.get_entity(entity_id)
note_title = entity.title
+19 -5
View File
@@ -158,7 +158,7 @@ Error editing note '{identifier}': {error_message}
@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
@@ -190,6 +190,8 @@ async def edit_note(
- "prepend": Add content to the beginning of the note (creates the note if it doesn't exist)
- "find_replace": Replace occurrences of find_text with content (note must exist)
- "replace_section": Replace content under a specific markdown header (note must exist)
- "insert_before_section": Insert content before a section heading without consuming it (note must exist)
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
content: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
@@ -257,7 +259,14 @@ async def edit_note(
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
valid_operations = [
"append",
"prepend",
"find_replace",
"replace_section",
"insert_before_section",
"insert_after_section",
]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
@@ -266,8 +275,9 @@ async def edit_note(
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
if operation in section_ops and not section:
raise ValueError("section parameter is required for section-based operations")
# Use the PATCH endpoint to edit the entity
try:
@@ -283,7 +293,7 @@ async def edit_note(
# Try to resolve the entity; for append/prepend, create it if not found
try:
entity_id = await knowledge_client.resolve_entity(identifier)
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
except Exception as resolve_error:
# Trigger: entity does not exist yet
# Why: append/prepend can meaningfully create a new note from the content,
@@ -389,6 +399,10 @@ async def edit_note(
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
elif operation == "insert_before_section":
summary.append(f"operation: Inserted content before section '{section}'")
elif operation == "insert_after_section":
summary.append(f"operation: Inserted content after section '{section}'")
# Count observations by category (reuse logic from write_note)
categories = {}
@@ -1,271 +0,0 @@
"""MCP tools for graph intelligence and FCM contracts."""
from typing import Any, Literal
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMImportRequest,
FCMRankActionsRequest,
FCMSimulateRequest,
GraphImpactRequest,
GraphLineageRequest,
GraphReindexRequest,
)
def _format_lineage_text(result: dict[str, Any]) -> str:
root = result["root"]["title"]
path_count = len(result.get("paths", []))
return f"# Graph Lineage\n\nRoot: {root}\nPaths: {path_count}"
def _format_impact_text(result: dict[str, Any]) -> str:
target = result["target"]["title"]
affected = len(result.get("affected", []))
return f"# Graph Impact\n\nTarget: {target}\nAffected: {affected}"
def _format_health_text(result: dict[str, Any]) -> str:
metrics = result["metrics"]
return (
"# Graph Health\n\n"
f"- orphan_rate: {metrics['orphan_rate']}\n"
f"- stale_central_nodes: {metrics['stale_central_nodes']}\n"
f"- overloaded_hubs: {metrics['overloaded_hubs']}\n"
f"- contradiction_candidates: {metrics['contradiction_candidates']}"
)
def _format_fcm_simulate_text(result: dict[str, Any]) -> str:
deltas = len(result.get("deltas", []))
converged = result["stability"]["converged"]
return f"# FCM Simulation\n\nDeltas: {deltas}\nConverged: {converged}"
def _format_fcm_rank_text(result: dict[str, Any]) -> str:
goal = result["goal"]["label"]
count = len(result.get("recommendations", []))
return f"# FCM Action Ranking\n\nGoal: {goal}\nRecommendations: {count}"
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_lineage(
start: str,
goal: str | None = None,
max_hops: int = 4,
relation_filters: list[str] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get lineage paths from a start node toward an optional goal."""
from basic_memory.mcp.clients import GraphClient
request = GraphLineageRequest(
start=start,
goal=goal,
max_hops=max_hops,
relation_filters=relation_filters or [],
)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.lineage(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_lineage_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_impact(
target: str,
horizon: int,
relation_filters: list[str] | None = None,
include_reasons: bool = True,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get impact radius from a target node."""
from basic_memory.mcp.clients import GraphClient
request = GraphImpactRequest(
target=target,
horizon=horizon,
relation_filters=relation_filters or [],
include_reasons=include_reasons,
)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.impact(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_impact_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_health(
scope: str | None = None,
timeframe: str | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get graph health metrics and issues."""
from basic_memory.mcp.clients import GraphClient
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.health(scope=scope, timeframe=timeframe)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_health_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def fcm_simulate(
actions: list[dict[str, Any]],
scenario: dict[str, Any] | None = None,
clamp_rules: list[dict[str, Any]] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Run an FCM simulation with optional scenario controls."""
from basic_memory.mcp.clients import FCMClient
request = FCMSimulateRequest.model_validate(
{
"actions": actions,
"scenario": scenario or {},
"clamp_rules": clamp_rules or [],
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.simulate(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_fcm_simulate_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def fcm_rank_actions(
goal: str,
constraints: dict[str, Any] | None = None,
top_k: int = 10,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Rank intervention actions for an FCM goal node."""
from basic_memory.mcp.clients import FCMClient
request = FCMRankActionsRequest.model_validate(
{
"goal": goal,
"constraints": constraints or {},
"top_k": top_k,
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.rank_actions(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_fcm_rank_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def fcm_import_model(
source: str,
format: Literal["csv_bundle_v1"] = "csv_bundle_v1",
merge_mode: Literal["replace", "upsert"] = "upsert",
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Import an FCM model from an external source."""
from basic_memory.mcp.clients import FCMClient
request = FCMImportRequest(source=source, format=format, merge_mode=merge_mode)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.import_model(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return (
"# FCM Import\n\n"
f"Import ID: {payload['import_id']}\n"
f"Nodes Loaded: {payload['nodes_loaded']}\n"
f"Edges Loaded: {payload['edges_loaded']}"
)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def fcm_export_model(
format: Literal["csv_bundle_v1"] = "csv_bundle_v1",
selection: dict[str, Any] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Export an FCM model selection."""
from basic_memory.mcp.clients import FCMClient
request = FCMExportRequest.model_validate(
{
"format": format,
"selection": selection or {},
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.export_model(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return (
"# FCM Export\n\n"
f"Export ID: {payload['export_id']}\n"
f"Node Count: {payload['node_count']}\n"
f"Edge Count: {payload['edge_count']}"
)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def graph_reindex(
mode: Literal["full", "incremental"] = "incremental",
reason: str | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Queue a graph reindex for the active project."""
from basic_memory.mcp.clients import GraphClient
request = GraphReindexRequest(mode=mode, reason=reason)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.reindex(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return f"# Graph Reindex\n\nJob ID: {payload['job_id']}\nStatus: {payload['status']}"
return payload
+21 -2
View File
@@ -6,6 +6,7 @@ from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_project_client
@@ -637,7 +638,7 @@ move_note("path/to/file.md", "{destination_path}/file.md")
"""Resolve and cache the source entity ID for the duration of this move."""
nonlocal resolved_entity_id
if resolved_entity_id is None:
resolved_entity_id = await knowledge_client.resolve_entity(identifier)
resolved_entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
return resolved_entity_id
try:
@@ -645,8 +646,26 @@ move_note("path/to/file.md", "{destination_path}/file.md")
source_entity = await knowledge_client.get_entity(resolved_entity_id)
if "." in source_entity.file_path:
source_ext = source_entity.file_path.split(".")[-1]
except ToolError as e:
# Trigger: strict=True resolve_entity raised because the entity was not found.
# Why: fail fast with a formatted error instead of silently falling through
# to extension defaults and failing later with a confusing message.
# Outcome: move_note returns a user-facing not-found error immediately.
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": str(e),
}
return _format_move_error_response(str(e), identifier, destination_path)
except Exception as e:
# If we can't fetch source metadata, continue with extension defaults.
# If we can't fetch source metadata (e.g. get_entity or file_path parsing fails),
# continue with extension defaults — the entity was at least resolved.
logger.debug(f"Could not fetch source entity for extension check: {e}")
# --- Resolve destination_folder into destination_path ---
+2 -2
View File
@@ -160,7 +160,7 @@ def _no_notes_guidance(note_type: str, tool_name: str) -> str:
f"## Next Steps\n\n"
f"1. **Create notes of this type** — use `write_note` with "
f'`note_type="{note_type}"` to create notes\n'
f"2. **Check existing types** — use `search_notes` with `entity_types` "
f"2. **Check existing types** — use `search_notes` with `note_types` "
f"filter to see what types exist\n"
f"3. **Browse content** — use `list_directory` or `recent_activity` to "
f"see what's in the project\n"
@@ -397,7 +397,7 @@ async def schema_infer(
f"share a consistent structure.\n\n"
f"## Suggestions\n"
f"1. **Use a more specific type** — try `search_notes` with "
f"`entity_types` filter to see what types exist\n"
f"`note_types` filter to see what types exist\n"
f"2. **Lower the threshold** — "
f'`schema_infer("{note_type}", threshold=0.1)` to include '
f"rarer fields\n"
+29 -8
View File
@@ -2,12 +2,14 @@
import re
from textwrap import dedent
from typing import List, Optional, Dict, Any, Literal
from typing import Annotated, List, Optional, Dict, Any, Literal
from loguru import logger
from fastmcp import Context
from pydantic import BeforeValidator
from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
@@ -165,7 +167,7 @@ def _format_search_error_response(
- Remove restrictive terms: Focus on the most important keywords
5. **Use filtering to narrow scope**:
- By content type: `search_notes("{project}","{query}", note_types=["note"])`
- By note type in frontmatter: `search_notes("{project}","{query}", note_types=["note"])`
- By recent content: `search_notes("{project}","{query}", after_date="1 week")`
- By entity type: `search_notes("{project}","{query}", entity_types=["observation"])`
@@ -305,11 +307,28 @@ async def search_notes(
page_size: int = 10,
search_type: str | None = None,
output_format: Literal["text", "json"] = "text",
note_types: List[str] | None = None,
entity_types: List[str] | None = None,
note_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead.",
] = None,
after_date: Optional[str] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
metadata_filters: Annotated[
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
] = None,
tags: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
] = None,
status: Optional[str] = None,
min_similarity: Optional[float] = None,
context: Context | None = None,
@@ -350,6 +369,7 @@ async def search_notes(
### Search Type Examples
- `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles
- `search_notes("work-docs", "docs/meeting-*", search_type="permalink")` - Pattern match permalinks
Note: Permalink patterns match the full path (e.g., "project/folder/chapter-13*", not just "chapter-13*").
- `search_notes("research", "keyword")` - Default search (hybrid when semantic is enabled,
text when disabled)
@@ -436,7 +456,7 @@ async def search_notes(
# Exact phrase search
results = await search_notes("\"weekly standup meeting\"")
# Search with note type filter
# Search with note type filter - type property in frontmatter
results = await search_notes(
"meeting notes",
note_types=["note"],
@@ -477,7 +497,8 @@ async def search_notes(
results = await search_notes("project planning", project="my-project")
"""
# Avoid mutable-default-argument footguns. Treat None as "no filter".
note_types = note_types or []
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
entity_types = entity_types or []
# Parse tag:<value> shorthand at tool level so it works with all search modes.
+12 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from typing import Annotated, Any, Dict, List, Optional
from fastmcp import Context
from mcp.types import ContentBlock, TextContent
@@ -28,8 +28,17 @@ async def search_notes_ui(
page: int = 1,
page_size: int = 10,
search_type: Optional[str] = None,
note_types: List[str] | None = None,
entity_types: List[str] | None = None,
note_types: Annotated[
List[str] | None,
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead.",
] = None,
after_date: Optional[str] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
+4 -3
View File
@@ -1,16 +1,17 @@
"""Write note tool for Basic Memory MCP server."""
import textwrap
from typing import List, Union, Optional, Literal
from typing import Annotated, List, Union, Optional, Literal
from loguru import logger
from pydantic import BeforeValidator
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
from basic_memory.mcp.server import mcp
from fastmcp import Context
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags, validate_project_path
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -28,7 +29,7 @@ async def write_note(
workspace: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
metadata: dict | None = None,
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
overwrite: bool | None = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
@@ -451,21 +451,36 @@ class SearchRepositoryBase(ABC):
return "\n\n".join(part for part in row_parts if part)
def _build_chunk_records(self, rows) -> list[dict[str, str]]:
records: list[dict[str, str]] = []
records_by_key: dict[str, dict[str, str]] = {}
duplicate_chunk_keys = 0
for row in rows:
source_text = self._compose_row_source_text(row)
chunks = self._split_text_into_chunks(source_text)
for chunk_index, chunk_text in enumerate(chunks):
chunk_key = f"{row.type}:{row.id}:{chunk_index}"
source_hash = hashlib.sha256(chunk_text.encode("utf-8")).hexdigest()
records.append(
{
"chunk_key": chunk_key,
"chunk_text": chunk_text,
"source_hash": source_hash,
}
)
return records
# Trigger: SQLite FTS5 can accumulate duplicate logical rows for the
# same search_index id because it does not enforce relational uniqueness.
# Why: duplicate chunk keys would schedule duplicate writes for the same
# chunk row and eventually trip UNIQUE(rowid) in search_vector_embeddings.
# Outcome: collapse chunk work to one deterministic record per chunk key.
if chunk_key in records_by_key:
duplicate_chunk_keys += 1
records_by_key[chunk_key] = {
"chunk_key": chunk_key,
"chunk_text": chunk_text,
"source_hash": source_hash,
}
if duplicate_chunk_keys:
logger.warning(
"Collapsed duplicate vector chunk keys before embedding sync: "
"project_id={project_id} duplicate_chunk_keys={duplicate_chunk_keys}",
project_id=self.project_id,
duplicate_chunk_keys=duplicate_chunk_keys,
)
return list(records_by_key.values())
# --- Text splitting ---
+5 -3
View File
@@ -140,10 +140,12 @@ def validate_timeframe(timeframe: str) -> str:
if parsed > now:
raise ValueError("Timeframe cannot be in the future") # pragma: no cover
# Could format the duration back to our standard format
days = (now - parsed).days
# Round to nearest day to handle DST transitions where an hour shift
# can cause e.g. "7d" to compute as 6 days + 23 hours
total_seconds = (now - parsed).total_seconds()
days = round(total_seconds / 86400)
# Could enforce reasonable limits
# Enforce reasonable limits
if days > 365:
raise ValueError("Timeframe should be <= 1 year")
@@ -1,318 +0,0 @@
"""Schemas for Local+ graph intelligence and FCM contracts."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# --- Graph contracts ---
class GraphLineageRequest(BaseModel):
"""Request contract for graph lineage queries."""
start: str
goal: str | None = None
max_hops: int = Field(default=4, ge=1, le=6)
relation_filters: list[str] = Field(default_factory=list)
class GraphNodeRef(BaseModel):
"""Minimal graph node descriptor."""
id: str
title: str
permalink: str | None = None
class GraphPathEdge(BaseModel):
"""Edge descriptor for lineage paths."""
relation: str
direction: Literal["outgoing", "incoming"]
class GraphLineagePath(BaseModel):
"""Single lineage path with scores and provenance."""
path_id: str
nodes: list[GraphNodeRef] = Field(default_factory=list)
edges: list[GraphPathEdge] = Field(default_factory=list)
deterministic_path_score: float
confidence: float
evidence_refs: list[str] = Field(default_factory=list)
class GraphLineageResponse(BaseModel):
"""Response contract for graph lineage queries."""
root: GraphNodeRef
paths: list[GraphLineagePath] = Field(default_factory=list)
generated_at: datetime
class GraphImpactRequest(BaseModel):
"""Request contract for impact-radius queries."""
target: str
horizon: int = Field(ge=1, le=4)
relation_filters: list[str] = Field(default_factory=list)
include_reasons: bool = True
class GraphImpactTarget(BaseModel):
"""Impact response target descriptor."""
id: str
title: str
class GraphImpactItem(BaseModel):
"""Affected node entry for impact responses."""
id: str
title: str
distance: int
impact_score: float
confidence: float
reasons: list[str] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class GraphImpactSummary(BaseModel):
"""Summary counters for impact responses."""
total_considered: int
total_returned: int
class GraphImpactResponse(BaseModel):
"""Response contract for impact-radius queries."""
target: GraphImpactTarget
affected: list[GraphImpactItem] = Field(default_factory=list)
summary: GraphImpactSummary
class GraphHealthMetrics(BaseModel):
"""Top-level graph health metrics."""
orphan_rate: float
stale_central_nodes: int
overloaded_hubs: int
contradiction_candidates: int
class GraphHealthIssue(BaseModel):
"""Actionable graph-health issue entry."""
issue_type: Literal[
"orphan",
"stale_central",
"overloaded_hub",
"contradiction_candidate",
]
entity_id: str
severity: Literal["low", "medium", "high"]
reason: str
suggested_action: str
confidence: float | None = None
class GraphHealthResponse(BaseModel):
"""Response contract for health checks."""
metrics: GraphHealthMetrics
issues: list[GraphHealthIssue] = Field(default_factory=list)
computed_at: datetime
class GraphReindexRequest(BaseModel):
"""Request contract for graph reindex scheduling."""
mode: Literal["full", "incremental"] = "incremental"
reason: str | None = None
class GraphReindexResponse(BaseModel):
"""Response contract for graph reindex scheduling."""
job_id: str
status: Literal["queued", "running", "completed", "failed"]
scheduled_at: datetime
# --- FCM contracts ---
class FCMAction(BaseModel):
"""Action delta for simulation input."""
node_id: str
delta: float
class FCMScenario(BaseModel):
"""Simulation runtime configuration."""
steps: int = 12
activation: Literal["tanh", "sigmoid", "bounded_linear"] = "tanh"
decay: float = 0.05
class FCMClampRule(BaseModel):
"""Clamp bounds for selected nodes."""
node_id: str
min: float
max: float
class FCMSimulateRequest(BaseModel):
"""Request contract for FCM simulation."""
actions: list[FCMAction]
scenario: FCMScenario = Field(default_factory=FCMScenario)
clamp_rules: list[FCMClampRule] = Field(default_factory=list)
class FCMNodeState(BaseModel):
"""Node state in baseline/projected vectors."""
node_id: str
state: float
class FCMNodeDelta(BaseModel):
"""Node delta entry in simulation output."""
node_id: str
delta: float
class FCMStability(BaseModel):
"""Simulation stability metadata."""
converged: bool
iterations_used: int
residual: float
class FCMInfluencer(BaseModel):
"""Top influencer entry for explanation payload."""
source: str
weight: float
class FCMExplanation(BaseModel):
"""Per-node explanation payload."""
node_id: str
top_influencers: list[FCMInfluencer] = Field(default_factory=list)
class FCMSimulateResponse(BaseModel):
"""Response contract for FCM simulation."""
baseline: list[FCMNodeState] = Field(default_factory=list)
projected: list[FCMNodeState] = Field(default_factory=list)
deltas: list[FCMNodeDelta] = Field(default_factory=list)
stability: FCMStability
confidence: float
explanations: list[FCMExplanation] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class FCMRankConstraints(BaseModel):
"""Constraint set for action ranking."""
max_negative_impact: float | None = None
required_tags: list[str] = Field(default_factory=list)
disallowed_nodes: list[str] = Field(default_factory=list)
class FCMRankActionsRequest(BaseModel):
"""Request contract for FCM action ranking."""
goal: str
constraints: FCMRankConstraints = Field(default_factory=FCMRankConstraints)
top_k: int = Field(default=10, ge=1, le=25)
class FCMGoalRef(BaseModel):
"""Goal descriptor for ranking output."""
node_id: str
label: str
class FCMRecommendation(BaseModel):
"""Ranked intervention candidate."""
action_node_id: str
expected_goal_delta: float
risk_penalty: float
net_score: float
confidence: float
rationale: list[str] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class FCMRankActionsResponse(BaseModel):
"""Response contract for action ranking."""
goal: FCMGoalRef
recommendations: list[FCMRecommendation] = Field(default_factory=list)
class FCMImportRequest(BaseModel):
"""Request contract for model import."""
source: str
format: Literal["csv_bundle_v1"] = "csv_bundle_v1"
merge_mode: Literal["replace", "upsert"] = "upsert"
class FCMImportResponse(BaseModel):
"""Response contract for model import."""
import_id: str
nodes_loaded: int
edges_loaded: int
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
class FCMExportSelection(BaseModel):
"""Scope selection for model export."""
scope: Literal["all", "tag", "subgraph"] = "all"
tag: str | None = None
seed_nodes: list[str] = Field(default_factory=list)
class FCMExportRequest(BaseModel):
"""Request contract for model export."""
format: Literal["csv_bundle_v1"] = "csv_bundle_v1"
selection: FCMExportSelection = Field(default_factory=FCMExportSelection)
class FCMExportFile(BaseModel):
"""Single file descriptor in an export response."""
name: str
path: str
class FCMExportResponse(BaseModel):
"""Response contract for model export."""
export_id: str
format: Literal["csv_bundle_v1"]
files: list[FCMExportFile] = Field(default_factory=list)
node_count: int
edge_count: int
metadata: dict[str, Any] | None = None
+18 -3
View File
@@ -65,7 +65,14 @@ class EditEntityRequest(BaseModel):
Supports various operation types for different editing scenarios.
"""
operation: Literal["append", "prepend", "find_replace", "replace_section"]
operation: Literal[
"append",
"prepend",
"find_replace",
"replace_section",
"insert_before_section",
"insert_after_section",
]
content: str
section: Optional[str] = None
find_text: Optional[str] = None
@@ -75,8 +82,16 @@ class EditEntityRequest(BaseModel):
@classmethod
def validate_section_for_replace_section(cls, v, info):
"""Ensure section is provided for replace_section operation."""
if info.data.get("operation") == "replace_section" and not v:
raise ValueError("section parameter is required for replace_section operation")
if (
info.data.get("operation")
in (
"replace_section",
"insert_before_section",
"insert_after_section",
)
and not v
):
raise ValueError("section parameter is required for section-based operations")
return v
@field_validator("find_text")
+8
View File
@@ -10,6 +10,11 @@ from basic_memory.schemas.v2.entity import (
ProjectResolveRequest,
ProjectResolveResponse,
)
from basic_memory.schemas.v2.graph import (
GraphEdge,
GraphNode,
GraphResponse,
)
from basic_memory.schemas.v2.resource import (
CreateResourceRequest,
UpdateResourceRequest,
@@ -25,6 +30,9 @@ __all__ = [
"DeleteDirectoryRequestV2",
"ProjectResolveRequest",
"ProjectResolveResponse",
"GraphEdge",
"GraphNode",
"GraphResponse",
"CreateResourceRequest",
"UpdateResourceRequest",
"ResourceResponse",
+31
View File
@@ -0,0 +1,31 @@
"""Graph visualization schemas for the knowledge graph endpoint."""
from typing import Optional
from pydantic import BaseModel, Field
class GraphNode(BaseModel):
"""A node in the knowledge graph visualization."""
external_id: str = Field(..., description="Entity external ID (UUID)")
title: str = Field(..., description="Entity title")
note_type: Optional[str] = Field(None, description="Note type (e.g., note, spec, task)")
file_path: str = Field(..., description="Relative file path")
class GraphEdge(BaseModel):
"""An edge in the knowledge graph visualization."""
from_id: str = Field(..., description="External ID of source entity")
to_id: str = Field(..., description="External ID of target entity")
relation_type: str = Field(..., description="Type of relation")
class GraphResponse(BaseModel):
"""Complete knowledge graph for visualization."""
nodes: list[GraphNode] = Field(default_factory=list, description="All entities as nodes")
edges: list[GraphEdge] = Field(
default_factory=list, description="All resolved relations as edges"
)
@@ -888,6 +888,14 @@ class EntityService(BaseService[EntityModel]):
raise ValueError("section cannot be empty or whitespace only")
return self.replace_section_content(current_content, section, content)
elif operation in ("insert_before_section", "insert_after_section"):
if not section:
raise ValueError("section is required for insert section operations")
if not section.strip():
raise ValueError("section cannot be empty or whitespace only")
position = "before" if operation == "insert_before_section" else "after"
return self.insert_relative_to_section(current_content, section, content, position)
else:
raise ValueError(f"Unsupported operation: {operation}")
@@ -979,6 +987,73 @@ class EntityService(BaseService[EntityModel]):
return "\n".join(result_lines)
def insert_relative_to_section(
self,
current_content: str,
section_header: str,
new_content: str,
position: str,
) -> str:
"""Insert content before or after a section heading without consuming it.
Unlike replace_section_content, this preserves the section heading and its
existing content. The new content is inserted immediately before or after
the heading line.
Args:
current_content: The current markdown content
section_header: The section header to anchor on (e.g., "## Section Name")
new_content: The content to insert
position: "before" to insert above the heading, "after" to insert below it
Returns:
The updated content with new_content inserted relative to the heading
Raises:
ValueError: If the section header is not found or appears more than once
"""
# Normalize the section header (ensure it starts with #)
if not section_header.startswith("#"):
section_header = "## " + section_header
lines = current_content.split("\n")
matching_indices = [
i for i, line in enumerate(lines) if line.strip() == section_header.strip()
]
if len(matching_indices) == 0:
raise ValueError(
f"Section '{section_header}' not found in document. "
f"Use replace_section to create a new section."
)
if len(matching_indices) > 1:
raise ValueError(
f"Multiple sections found with header '{section_header}'. "
f"Section insertion requires unique headers."
)
idx = matching_indices[0]
if position == "before":
# Insert new content before the section heading
before = lines[:idx]
after = lines[idx:]
# Ensure blank line separation
insert_lines = new_content.rstrip("\n").split("\n")
if before and before[-1].strip() != "":
insert_lines = [""] + insert_lines
return "\n".join(before + insert_lines + [""] + after)
else:
# Insert new content after the section heading line
before = lines[: idx + 1]
after = lines[idx + 1 :]
insert_lines = new_content.rstrip("\n").split("\n")
# Ensure blank line separation so inserted text doesn't merge
# with existing section content into a single paragraph
if after and after[0].strip() != "":
insert_lines = insert_lines + [""]
return "\n".join(before + insert_lines + after)
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
"""Prepend content after frontmatter, preserving frontmatter structure."""
-96
View File
@@ -1,96 +0,0 @@
"""Service layer for FCM contract endpoints."""
from uuid import uuid4
from basic_memory.schemas.graph_intelligence import (
FCMExportFile,
FCMExportRequest,
FCMExportResponse,
FCMGoalRef,
FCMImportRequest,
FCMImportResponse,
FCMNodeDelta,
FCMNodeState,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMRecommendation,
FCMSimulateRequest,
FCMSimulateResponse,
FCMStability,
)
class FCMService:
"""FCM contract service.
Phase 1 keeps deterministic behavior so API and tool surfaces stabilize
before introducing advanced simulation engines.
"""
async def simulate(self, request: FCMSimulateRequest) -> FCMSimulateResponse:
"""Return deterministic baseline/projected state vectors."""
baseline = [FCMNodeState(node_id=action.node_id, state=0.0) for action in request.actions]
projected = [
FCMNodeState(node_id=action.node_id, state=action.delta) for action in request.actions
]
deltas = [
FCMNodeDelta(node_id=action.node_id, delta=action.delta) for action in request.actions
]
return FCMSimulateResponse(
baseline=baseline,
projected=projected,
deltas=deltas,
stability=FCMStability(
converged=True,
iterations_used=min(request.scenario.steps, 5),
residual=0.0,
),
confidence=0.5,
explanations=[],
evidence_refs=[],
)
async def rank_actions(self, request: FCMRankActionsRequest) -> FCMRankActionsResponse:
"""Return deterministic ranked actions for a target goal."""
recommendations = [
FCMRecommendation(
action_node_id=f"{request.goal}:action:{idx + 1}",
expected_goal_delta=0.25 - (idx * 0.01),
risk_penalty=0.05 + (idx * 0.005),
net_score=0.20 - (idx * 0.015),
confidence=0.5,
rationale=["Contract skeleton recommendation"],
evidence_refs=[],
)
for idx in range(min(request.top_k, 3))
]
return FCMRankActionsResponse(
goal=FCMGoalRef(node_id=request.goal, label=request.goal),
recommendations=recommendations,
)
async def import_model(self, request: FCMImportRequest) -> FCMImportResponse:
"""Return deterministic import metadata."""
_ = request
return FCMImportResponse(
import_id=str(uuid4()),
nodes_loaded=0,
edges_loaded=0,
warnings=[],
errors=[],
)
async def export_model(self, request: FCMExportRequest) -> FCMExportResponse:
"""Return deterministic export metadata and file descriptors."""
scope = request.selection.scope
return FCMExportResponse(
export_id=str(uuid4()),
format=request.format,
files=[
FCMExportFile(name="nodes.csv", path=f"/tmp/{scope}-nodes.csv"),
FCMExportFile(name="edges.csv", path=f"/tmp/{scope}-edges.csv"),
],
node_count=0,
edge_count=0,
metadata={"scope": scope},
)
@@ -1,122 +0,0 @@
"""Service layer for graph intelligence contract endpoints."""
from datetime import datetime, timezone
from uuid import uuid4
from basic_memory.schemas.graph_intelligence import (
GraphHealthMetrics,
GraphHealthResponse,
GraphImpactItem,
GraphImpactRequest,
GraphImpactResponse,
GraphImpactSummary,
GraphImpactTarget,
GraphLineagePath,
GraphLineageRequest,
GraphLineageResponse,
GraphNodeRef,
GraphPathEdge,
GraphReindexResponse,
)
def _normalize_memory_ref(value: str) -> str:
"""Normalize user input into a memory:// reference string."""
if value.startswith("memory://"):
return value
return f"memory://{value}"
def _normalize_node_id(value: str) -> str:
"""Return a stable node id for contract skeleton outputs."""
return value.removeprefix("memory://")
class GraphIntelligenceService:
"""Graph intelligence contract service.
Phase 1 behavior is intentionally deterministic and lightweight so routing,
clients, and contract tests can ship before deeper traversal engines.
"""
async def lineage(self, request: GraphLineageRequest) -> GraphLineageResponse:
"""Return a deterministic lineage payload for the requested root/goal."""
root_ref = _normalize_memory_ref(request.start)
root = GraphNodeRef(
id=_normalize_node_id(root_ref),
title=_normalize_node_id(root_ref),
permalink=_normalize_node_id(root_ref),
)
nodes = [root]
edges: list[GraphPathEdge] = []
if request.goal:
goal_ref = _normalize_memory_ref(request.goal)
nodes.append(
GraphNodeRef(
id=_normalize_node_id(goal_ref),
title=_normalize_node_id(goal_ref),
permalink=_normalize_node_id(goal_ref),
)
)
edges.append(GraphPathEdge(relation="related_to", direction="outgoing"))
path = GraphLineagePath(
path_id=f"path-{uuid4()}",
nodes=nodes,
edges=edges,
deterministic_path_score=1.0 if request.goal else 0.5,
confidence=0.5,
evidence_refs=[root_ref],
)
return GraphLineageResponse(
root=root,
paths=[path],
generated_at=datetime.now(timezone.utc),
)
async def impact(self, request: GraphImpactRequest) -> GraphImpactResponse:
"""Return a deterministic impact preview payload."""
target_id = _normalize_node_id(_normalize_memory_ref(request.target))
affected = [
GraphImpactItem(
id=f"{target_id}:neighbor:1",
title=f"{target_id} dependent",
distance=min(request.horizon, 1),
impact_score=0.55,
confidence=0.5,
reasons=["Connected via typed relation in contract skeleton"],
evidence_refs=[_normalize_memory_ref(request.target)],
)
]
if not request.include_reasons:
affected[0].reasons = []
return GraphImpactResponse(
target=GraphImpactTarget(id=target_id, title=target_id),
affected=affected,
summary=GraphImpactSummary(total_considered=1, total_returned=1),
)
async def health(self, scope: str | None, timeframe: str | None) -> GraphHealthResponse:
"""Return deterministic baseline health metrics."""
_ = (scope, timeframe)
return GraphHealthResponse(
metrics=GraphHealthMetrics(
orphan_rate=0.0,
stale_central_nodes=0,
overloaded_hubs=0,
contradiction_candidates=0,
),
issues=[],
computed_at=datetime.now(timezone.utc),
)
async def start_reindex_job(self) -> GraphReindexResponse:
"""Create reindex job metadata for queued responses."""
return GraphReindexResponse(
job_id=str(uuid4()),
status="queued",
scheduled_at=datetime.now(timezone.utc),
)
+72 -46
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Dict, Optional, Sequence
from loguru import logger
from sqlalchemy import text
from sqlalchemy.exc import OperationalError as SAOperationalError
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
@@ -1004,56 +1005,81 @@ class ProjectService:
)
total_indexed_entities = si_result.scalar() or 0
chunks_result = await self.repository.execute_query(
text("SELECT COUNT(*) FROM search_vector_chunks WHERE project_id = :project_id"),
{"project_id": project_id},
)
total_chunks = chunks_result.scalar() or 0
entities_with_chunks_result = await self.repository.execute_query(
text(
"SELECT COUNT(DISTINCT entity_id) FROM search_vector_chunks "
"WHERE project_id = :project_id"
),
{"project_id": project_id},
)
total_entities_with_chunks = entities_with_chunks_result.scalar() or 0
# Embeddings count — join pattern differs between SQLite and Postgres
if is_postgres:
embeddings_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"JOIN search_vector_embeddings e ON e.chunk_id = c.id "
"WHERE c.project_id = :project_id"
)
else:
embeddings_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"JOIN search_vector_embeddings e ON e.rowid = c.id "
"WHERE c.project_id = :project_id"
try:
chunks_result = await self.repository.execute_query(
text("SELECT COUNT(*) FROM search_vector_chunks WHERE project_id = :project_id"),
{"project_id": project_id},
)
total_chunks = chunks_result.scalar() or 0
embeddings_result = await self.repository.execute_query(
embeddings_sql, {"project_id": project_id}
)
total_embeddings = embeddings_result.scalar() or 0
# Orphaned chunks (chunks without embeddings — indicates interrupted indexing)
if is_postgres:
orphan_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"LEFT JOIN search_vector_embeddings e ON e.chunk_id = c.id "
"WHERE c.project_id = :project_id AND e.chunk_id IS NULL"
)
else:
orphan_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"LEFT JOIN search_vector_embeddings e ON e.rowid = c.id "
"WHERE c.project_id = :project_id AND e.rowid IS NULL"
entities_with_chunks_result = await self.repository.execute_query(
text(
"SELECT COUNT(DISTINCT entity_id) FROM search_vector_chunks "
"WHERE project_id = :project_id"
),
{"project_id": project_id},
)
total_entities_with_chunks = entities_with_chunks_result.scalar() or 0
orphan_result = await self.repository.execute_query(orphan_sql, {"project_id": project_id})
orphaned_chunks = orphan_result.scalar() or 0
# Embeddings count — join pattern differs between SQLite and Postgres
if is_postgres:
embeddings_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"JOIN search_vector_embeddings e ON e.chunk_id = c.id "
"WHERE c.project_id = :project_id"
)
else:
embeddings_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"JOIN search_vector_embeddings e ON e.rowid = c.id "
"WHERE c.project_id = :project_id"
)
embeddings_result = await self.repository.execute_query(
embeddings_sql, {"project_id": project_id}
)
total_embeddings = embeddings_result.scalar() or 0
# Orphaned chunks (chunks without embeddings — indicates interrupted indexing)
if is_postgres:
orphan_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"LEFT JOIN search_vector_embeddings e ON e.chunk_id = c.id "
"WHERE c.project_id = :project_id AND e.chunk_id IS NULL"
)
else:
orphan_sql = text(
"SELECT COUNT(*) FROM search_vector_chunks c "
"LEFT JOIN search_vector_embeddings e ON e.rowid = c.id "
"WHERE c.project_id = :project_id AND e.rowid IS NULL"
)
orphan_result = await self.repository.execute_query(
orphan_sql, {"project_id": project_id}
)
orphaned_chunks = orphan_result.scalar() or 0
except SAOperationalError as exc:
# Trigger: sqlite_master can list vec0 virtual tables even when sqlite-vec
# is not loaded in the current Python runtime.
# Why: project info should degrade gracefully instead of crashing on stats queries.
# Outcome: report vector tables as unavailable and point the user to install the
# missing dependency before rebuilding embeddings.
if is_postgres or "no such module: vec0" not in str(exc).lower():
raise
return EmbeddingStatus(
semantic_search_enabled=True,
embedding_provider=provider,
embedding_model=model,
embedding_dimensions=dimensions,
total_indexed_entities=total_indexed_entities,
vector_tables_exist=False,
reindex_recommended=True,
reindex_reason=(
"SQLite vector tables exist but sqlite-vec is unavailable in this Python "
"environment — install/update basic-memory, then run: bm reindex --embeddings"
),
)
# --- Reindex recommendation logic (priority order) ---
reindex_recommended = False
+29 -3
View File
@@ -293,12 +293,16 @@ class SyncService:
for path in report.deleted:
await self.handle_delete(path)
# then new and modified
# then new and modified — collect entity IDs for batch vector embedding
synced_entity_ids: list[int] = []
for path in report.new:
entity, _ = await self.sync_file(path, new=True)
if entity is not None:
synced_entity_ids.append(entity.id)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
elif await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
@@ -312,8 +316,10 @@ class SyncService:
for path in report.modified:
entity, _ = await self.sync_file(path, new=False)
if entity is not None:
synced_entity_ids.append(entity.id)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
elif await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
@@ -331,6 +337,26 @@ class SyncService:
else:
logger.info("Skipping relation resolution - no file changes detected")
# Batch-generate vector embeddings for all synced entities
if synced_entity_ids and self.app_config.semantic_search_enabled:
try:
logger.info(
f"Generating semantic embeddings for {len(synced_entity_ids)} entities..."
)
batch_result = await self.search_service.sync_entity_vectors_batch(
synced_entity_ids
)
logger.info(
f"Semantic embeddings complete: "
f"synced={batch_result.entities_synced}, "
f"failed={batch_result.entities_failed}"
)
except SemanticDependenciesMissingError:
logger.warning(
"Semantic search dependencies missing — vector embeddings skipped. "
"Run 'bm reindex --embeddings' after resolving the dependency issue."
)
# Update scan watermark after successful sync
# Use the timestamp from sync start (not end) to ensure we catch files
# created during the sync on the next iteration
+63 -3
View File
@@ -1,5 +1,6 @@
"""Utility functions for basic-memory."""
import json
import os
import logging
@@ -7,7 +8,7 @@ import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Protocol, Union, runtime_checkable, List, Optional
from typing import Any, Protocol, Union, runtime_checkable, List, Optional
from loguru import logger
from unidecode import unidecode
@@ -66,6 +67,7 @@ class PathLike(Protocol):
# In type annotations, use Union[Path, str] instead of FilePath for now
# This preserves compatibility with existing code while we migrate
FilePath = Union[Path, str]
WINDOWS_LOG_FILE_RETENTION = 5
def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str:
@@ -250,7 +252,7 @@ def setup_logging(
log_to_file: bool = False,
log_to_stdout: bool = False,
structured_context: bool = False,
) -> None: # pragma: no cover
) -> None:
"""Configure logging with explicit settings.
This function provides a simple, explicit interface for configuring logging.
@@ -273,8 +275,14 @@ def setup_logging(
# Add file handler with rotation
if log_to_file:
log_path = Path.home() / ".basic-memory" / "basic-memory.log"
# Trigger: Windows does not allow renaming an open file held by another process.
# Why: multiple basic-memory processes can share the same log directory at once.
# Outcome: use per-process log files on Windows so log rotation stays local.
log_filename = f"basic-memory-{os.getpid()}.log" if os.name == "nt" else "basic-memory.log"
log_path = Path.home() / ".basic-memory" / log_filename
log_path.parent.mkdir(parents=True, exist_ok=True)
if os.name == "nt":
_cleanup_windows_log_files(log_path.parent, log_path.name)
# Keep logging synchronous (enqueue=False) to avoid background logging threads.
# Background threads are a common source of "hang on exit" issues in CLI/test runs.
logger.add(
@@ -308,6 +316,28 @@ def setup_logging(
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
def _cleanup_windows_log_files(log_dir: Path, current_log_name: str) -> None:
"""Trim stale per-process Windows log files so the directory stays bounded."""
stale_logs = [
path
for path in log_dir.glob("basic-memory-*.log*")
if path.is_file() and path.name != current_log_name
]
if len(stale_logs) <= WINDOWS_LOG_FILE_RETENTION - 1:
return
# Trigger: per-process log filenames avoid Windows rename contention but fragment retention.
# Why: loguru retention applies per sink, not across the whole basic-memory log directory.
# Outcome: keep only the newest stale PID logs so repeated CLI/server launches stay bounded.
stale_logs.sort(key=lambda path: path.stat().st_mtime, reverse=True)
for stale_log in stale_logs[WINDOWS_LOG_FILE_RETENTION - 1 :]:
try:
stale_log.unlink()
except OSError:
logger.debug("Failed to delete stale Windows log file: {path}", path=stale_log)
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
"""Parse tags from various input formats into a consistent list.
@@ -356,6 +386,36 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
return []
def coerce_list(v: Any) -> Any:
"""Coerce string input to list for MCP clients that serialize lists as strings."""
if v is None:
return v
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return parsed
except (json.JSONDecodeError, TypeError):
pass
# Single string value — wrap in a list
return [v]
return v
def coerce_dict(v: Any) -> Any:
"""Coerce string input to dict for MCP clients that serialize dicts as strings."""
if v is None:
return v
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError):
pass
return v
def normalize_newlines(multiline: str) -> str:
"""Replace any \r\n, \r, or \n with the native newline.
@@ -208,7 +208,7 @@ def test_edit_note_replace_section_fails_without_section(
)
assert result.exit_code != 0
assert "section parameter is required for replace_section operation" in result.output
assert "section parameter is required for section-based operations" in result.output
def test_edit_note_append_creates_nonexistent_note_cli(
+30 -5
View File
@@ -307,8 +307,13 @@ async def test_delete_note_by_file_path(mcp_server, app, test_project):
@pytest.mark.asyncio
async def test_delete_note_case_insensitive(mcp_server, app, test_project):
"""Test that note deletion is case insensitive for titles."""
async def test_delete_note_rejects_case_mismatch(mcp_server, app, test_project):
"""Test that delete_note with wrong case does not fuzzy-match to an existing note.
Strict resolution (#649) prevents destructive operations from silently
resolving to a different note via fuzzy search. Case-mismatched titles
should be rejected, not resolved to the nearest match.
"""
async with Client(mcp_server) as client:
# Create a note with mixed case
@@ -323,7 +328,7 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
},
)
# Try to delete with different case
# Try to delete with different case — should NOT find the note
delete_result = await client.call_tool(
"delete_note",
{
@@ -332,8 +337,28 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
},
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower()
# Should return False (not found) — strict mode rejects fuzzy matches
assert "false" in delete_result.content[0].text.lower()
# Verify the note still exists using the exact title
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "CamelCase Note Title",
},
)
assert "Testing case sensitivity" in read_result.content[0].text
# Delete with exact title should succeed
delete_result2 = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "CamelCase Note Title",
},
)
assert "true" in delete_result2.content[0].text.lower()
@pytest.mark.asyncio
@@ -710,3 +710,81 @@ async def test_edit_note_using_different_identifiers(mcp_server, app, test_proje
assert "Edited by title." in content
assert "Edited by permalink." in content
assert "Edited by folder/title." in content
@pytest.mark.asyncio
async def test_edit_note_append_autocreate_does_not_fuzzy_match(mcp_server, app, test_project):
"""Reproduces #649: edit_note append must auto-create, not fuzzy-match to an existing note.
Creates two notes, then attempts to append to a nonexistent identifier.
The tool should create a new note, and neither existing note should be modified.
"""
async with Client(mcp_server) as client:
# Create two notes that could be fuzzy-matched
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Routing Test A",
"directory": "test",
"content": "# Routing Test A\n\nContent A.",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Routing Test B",
"directory": "test",
"content": "# Routing Test B\n\nContent B.",
},
)
# Attempt to edit a nonexistent note — should error, not silently edit A or B
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Routing Test NONEXISTENT",
"operation": "append",
"content": "\n\nThis should NOT appear in any note.",
},
)
edit_text = edit_result.content[0].text
# append to nonexistent creates a new note — verify it did NOT edit A or B
assert "Created note (append)" in edit_text
assert "fileCreated: true" in edit_text
# Verify neither A nor B was modified
read_a = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Routing Test A"},
)
content_a = read_a.content[0].text
assert "Content A" in content_a
assert "This should NOT appear" not in content_a
read_b = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Routing Test B"},
)
content_b = read_b.content[0].text
assert "Content B" in content_b
assert "This should NOT appear" not in content_b
# Now test find_replace on nonexistent — should error
edit_result2 = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Routing Test NONEXISTENT AGAIN",
"operation": "find_replace",
"content": "replaced",
"find_text": "Content",
},
)
error_text = edit_result2.content[0].text
assert "Edit Failed" in error_text
@@ -716,3 +716,56 @@ async def test_move_note_destination_folder_mutually_exclusive(mcp_server, app,
error_text = move_result.content[0].text
assert "# Move Failed - Invalid Parameters" in error_text
assert "Cannot specify both" in error_text
@pytest.mark.asyncio
async def test_move_note_strict_resolution_rejects_fuzzy_match(mcp_server, app, test_project):
"""move_note must not fuzzy-match a nonexistent identifier to an existing note (#649)."""
async with Client(mcp_server) as client:
# Create two notes that could be fuzzy-matched
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Move Strict Test A",
"directory": "test",
"content": "# Move Strict Test A\n\nContent A.",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Move Strict Test B",
"directory": "test",
"content": "# Move Strict Test B\n\nContent B.",
},
)
# Attempt to move a nonexistent note — should error, not move A or B
move_result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Move Strict Test NONEXISTENT",
"destination_path": "archive/Moved.md",
},
)
assert len(move_result.content) == 1
error_text = move_result.content[0].text
assert "# Move Failed" in error_text
# Verify neither A nor B was moved
read_a = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Move Strict Test A"},
)
assert "Content A" in read_a.content[0].text
read_b = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Move Strict Test B"},
)
assert "Content B" in read_b.content[0].text
@@ -0,0 +1,167 @@
"""Integration tests for MCP tools accepting string-serialized list/dict params.
Goes through the full FastMCP Client validate_call tool function path,
which is where Pydantic rejects strings for list/dict params.
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_search_notes_entity_types_as_string(mcp_server, app, test_project):
"""search_notes should accept entity_types as a JSON string via MCP protocol."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Entity Type Coerce Test",
"directory": "test",
"content": "# Test\nContent for entity type coercion",
},
)
# MCP client sends entity_types as a string
result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "coercion",
"entity_types": '["entity"]',
},
)
text = result.content[0].text
assert "Search Failed" not in text
@pytest.mark.asyncio
async def test_search_notes_note_types_as_string(mcp_server, app, test_project):
"""search_notes should accept note_types as a JSON string via MCP protocol."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Note Type Coerce Test",
"directory": "test",
"content": "# Test\nContent for note type coercion",
},
)
result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "coercion",
"note_types": '["note"]',
},
)
text = result.content[0].text
assert "Search Failed" not in text
@pytest.mark.asyncio
async def test_search_notes_tags_as_string(mcp_server, app, test_project):
"""search_notes should accept tags as a JSON string via MCP protocol."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Tags Coerce Test",
"directory": "test",
"content": "# Test\nTagged content for coercion",
"tags": "alpha",
},
)
result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "tagged",
"tags": '["alpha"]',
},
)
text = result.content[0].text
assert "Search Failed" not in text
@pytest.mark.asyncio
async def test_search_notes_metadata_filters_as_string(mcp_server, app, test_project):
"""search_notes should accept metadata_filters as a JSON string via MCP protocol."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Metadata Coerce Test",
"directory": "test",
"content": "# Test\nMetadata content for coercion",
},
)
result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "metadata",
"metadata_filters": '{"type": "note"}',
},
)
text = result.content[0].text
assert "Search Failed" not in text
@pytest.mark.asyncio
async def test_write_note_metadata_as_string(mcp_server, app, test_project):
"""write_note should accept metadata as a JSON string via MCP protocol."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "String Metadata Note",
"directory": "test",
"content": "# Test\nWith string metadata",
"metadata": '{"priority": "high"}',
},
)
text = result.content[0].text
assert "Created note" in text or "Updated note" in text
@pytest.mark.asyncio
async def test_canvas_nodes_edges_as_string(mcp_server, app, test_project):
"""canvas should accept nodes and edges as JSON strings via MCP protocol."""
import json
nodes = [
{
"id": "n1",
"type": "text",
"text": "Hello",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
}
]
edges = [
{"id": "e1", "fromNode": "n1", "toNode": "n1", "label": "self"}
]
async with Client(mcp_server) as client:
result = await client.call_tool(
"canvas",
{
"project": test_project.name,
"title": "Coerce Canvas Test",
"directory": "test",
"nodes": json.dumps(nodes),
"edges": json.dumps(edges),
},
)
text = result.content[0].text
assert "Created" in text or "Updated" in text
@@ -1,120 +0,0 @@
"""Tests for v2 graph intelligence and FCM routers."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_graph_lineage_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/graph/lineage",
json={"start": "memory://specs/search"},
)
assert response.status_code == 200
data = response.json()
assert set(["root", "paths", "generated_at"]).issubset(data.keys())
assert data["root"]["id"] == "specs/search"
assert isinstance(data["paths"], list)
@pytest.mark.asyncio
async def test_graph_impact_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/graph/impact",
json={"target": "memory://specs/search", "horizon": 2},
)
assert response.status_code == 200
data = response.json()
assert set(["target", "affected", "summary"]).issubset(data.keys())
assert data["summary"]["total_considered"] >= data["summary"]["total_returned"]
@pytest.mark.asyncio
async def test_graph_health_contract(client: AsyncClient, v2_project_url: str):
response = await client.get(
f"{v2_project_url}/graph/health",
params={"scope": "specs", "timeframe": "30d"},
)
assert response.status_code == 200
data = response.json()
assert set(["metrics", "issues", "computed_at"]).issubset(data.keys())
assert "orphan_rate" in data["metrics"]
@pytest.mark.asyncio
async def test_graph_reindex_schedules_task(
client: AsyncClient,
v2_project_url: str,
task_scheduler_spy: list[dict[str, object]],
):
response = await client.post(
f"{v2_project_url}/graph/reindex",
json={"mode": "full", "reason": "contract test"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "queued"
assert data["job_id"]
assert task_scheduler_spy
last = task_scheduler_spy[-1]
assert last["task_name"] == "reindex_graph_project"
assert last["payload"]["mode"] == "full"
assert last["payload"]["reason"] == "contract test"
@pytest.mark.asyncio
async def test_fcm_simulate_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/simulate",
json={"actions": [{"node_id": "test-node", "delta": 0.2}]},
)
assert response.status_code == 200
data = response.json()
assert set(["baseline", "projected", "deltas", "stability", "confidence"]).issubset(data.keys())
assert data["stability"]["converged"] is True
@pytest.mark.asyncio
async def test_fcm_rank_actions_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/rank-actions",
json={"goal": "reduce-regressions", "top_k": 2},
)
assert response.status_code == 200
data = response.json()
assert set(["goal", "recommendations"]).issubset(data.keys())
assert len(data["recommendations"]) <= 2
@pytest.mark.asyncio
async def test_fcm_import_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/import",
json={"source": "/tmp/model.csv", "format": "csv_bundle_v1"},
)
assert response.status_code == 200
data = response.json()
assert set(["import_id", "nodes_loaded", "edges_loaded", "warnings", "errors"]).issubset(
data.keys()
)
@pytest.mark.asyncio
async def test_fcm_export_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/export",
json={"format": "csv_bundle_v1", "selection": {"scope": "all"}},
)
assert response.status_code == 200
data = response.json()
assert set(["export_id", "format", "files", "node_count", "edge_count"]).issubset(data.keys())
assert len(data["files"]) == 2
@@ -1,184 +0,0 @@
"""Tests for graph/FCM CLI tool JSON passthrough commands."""
import json
from unittest.mock import AsyncMock, patch
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
@patch(
"basic_memory.cli.commands.tool.mcp_graph_lineage",
new_callable=AsyncMock,
return_value={
"root": {"id": "specs/search"},
"paths": [],
"generated_at": "2026-03-05T00:00:00Z",
},
)
def test_graph_lineage_json_output(mock_tool):
result = runner.invoke(cli_app, ["tool", "graph-lineage", "memory://specs/search"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["root"]["id"] == "specs/search"
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_graph_impact",
new_callable=AsyncMock,
return_value={
"target": {"id": "specs/search", "title": "specs/search"},
"affected": [],
"summary": {"total_considered": 0, "total_returned": 0},
},
)
def test_graph_impact_passthrough(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"graph-impact",
"memory://specs/search",
"--horizon",
"3",
"--relation-filter",
"depends_on",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["horizon"] == 3
assert mock_tool.call_args.kwargs["relation_filters"] == ["depends_on"]
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_graph_health",
new_callable=AsyncMock,
return_value={
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0,
},
"issues": [],
"computed_at": "2026-03-05T00:00:00Z",
},
)
def test_graph_health_json_output(mock_tool):
result = runner.invoke(
cli_app,
["tool", "graph-health", "--scope", "specs", "--timeframe", "30d"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert "metrics" in data
assert mock_tool.call_args.kwargs["scope"] == "specs"
assert mock_tool.call_args.kwargs["timeframe"] == "30d"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_simulate",
new_callable=AsyncMock,
return_value={
"baseline": [],
"projected": [],
"deltas": [],
"stability": {"converged": True, "iterations_used": 1, "residual": 0.0},
"confidence": 0.5,
},
)
def test_fcm_simulate_json_output(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"fcm-simulate",
"--actions-json",
'[{"node_id":"n1","delta":0.2}]',
"--scenario-json",
'{"steps":8}',
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["actions"] == [{"node_id": "n1", "delta": 0.2}]
assert mock_tool.call_args.kwargs["scenario"] == {"steps": 8}
def test_fcm_simulate_invalid_actions_json():
result = runner.invoke(
cli_app,
["tool", "fcm-simulate", "--actions-json", '{"node_id":"n1","delta":0.2}'],
)
assert result.exit_code == 1
assert "expected a JSON array" in result.output
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_rank_actions",
new_callable=AsyncMock,
return_value={"goal": {"node_id": "g1", "label": "g1"}, "recommendations": []},
)
def test_fcm_rank_actions_passthrough(mock_tool):
result = runner.invoke(
cli_app,
["tool", "fcm-rank-actions", "g1", "--constraints-json", '{"required_tags":["risk"]}'],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["constraints"] == {"required_tags": ["risk"]}
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_import_model",
new_callable=AsyncMock,
return_value={
"import_id": "imp-1",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": [],
"errors": [],
},
)
def test_fcm_import_model_json_output(mock_tool):
result = runner.invoke(
cli_app,
["tool", "fcm-import-model", "/tmp/model.csv", "--format", "csv_bundle_v1"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["import_id"] == "imp-1"
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_export_model",
new_callable=AsyncMock,
return_value={
"export_id": "exp-1",
"format": "csv_bundle_v1",
"files": [],
"node_count": 0,
"edge_count": 0,
},
)
def test_fcm_export_model_json_output(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"fcm-export-model",
"--format",
"csv_bundle_v1",
"--selection-json",
'{"scope":"all"}',
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["export_id"] == "exp-1"
assert mock_tool.call_args.kwargs["selection"] == {"scope": "all"}
+4 -2
View File
@@ -122,15 +122,17 @@ def test_project_list_shows_local_cloud_presence_and_routes(
assert "Local Path" in result.stdout
assert "Cloud Path" in result.stdout
assert "CLI Route" in result.stdout
assert "MCP (stdio)" in result.stdout
assert "MCP" in result.stdout
assert "MCP (stdio)" not in result.stdout
lines = result.stdout.splitlines()
alpha_line = next(line for line in lines if "│ alpha" in line)
beta_line = next(line for line in lines if "│ beta" in line)
assert "local" in alpha_line # CLI route for alpha
assert "stdio" in alpha_line # MCP transport for local-mode project
assert "cloud" in beta_line # CLI route for beta
assert "n/a" in beta_line # MCP stdio route is unavailable for cloud-only projects
assert "http" in beta_line # MCP transport for cloud-mode project
assert "alpha-local" in result.stdout
assert "/alpha" in result.stdout
assert "/beta" in result.stdout
-216
View File
@@ -1,216 +0,0 @@
"""Tests for graph and FCM typed clients."""
from unittest.mock import MagicMock
import pytest
from basic_memory.mcp.clients import FCMClient, GraphClient
class TestGraphClient:
def test_init(self):
mock_http = MagicMock()
client = GraphClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/graph"
@pytest.mark.asyncio
async def test_lineage(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphLineageRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"root": {"id": "specs/search", "title": "specs/search", "permalink": "specs/search"},
"paths": [],
"generated_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/lineage" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.lineage(GraphLineageRequest(start="memory://specs/search"))
assert result.root.id == "specs/search"
@pytest.mark.asyncio
async def test_impact(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphImpactRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"target": {"id": "specs/search", "title": "specs/search"},
"affected": [],
"summary": {"total_considered": 0, "total_returned": 0},
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/impact" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.impact(GraphImpactRequest(target="memory://specs/search", horizon=2))
assert result.summary.total_returned == 0
@pytest.mark.asyncio
async def test_health(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
mock_response = MagicMock()
mock_response.json.return_value = {
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0,
},
"issues": [],
"computed_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/health" in url
assert kwargs["params"]["scope"] == "specs"
return mock_response
monkeypatch.setattr(graph_mod, "call_get", mock_call_get)
client = GraphClient(MagicMock(), "proj-123")
result = await client.health(scope="specs", timeframe="30d")
assert result.metrics.orphan_rate == 0.0
@pytest.mark.asyncio
async def test_reindex(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphReindexRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"job_id": "job-123",
"status": "queued",
"scheduled_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/reindex" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.reindex(GraphReindexRequest(mode="full"))
assert result.status == "queued"
class TestFCMClient:
def test_init(self):
mock_http = MagicMock()
client = FCMClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/fcm"
@pytest.mark.asyncio
async def test_simulate(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMSimulateRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"baseline": [{"node_id": "n1", "state": 0.0}],
"projected": [{"node_id": "n1", "state": 0.2}],
"deltas": [{"node_id": "n1", "delta": 0.2}],
"stability": {"converged": True, "iterations_used": 3, "residual": 0.0},
"confidence": 0.5,
"explanations": [],
"evidence_refs": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/simulate" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMSimulateRequest(actions=[{"node_id": "n1", "delta": 0.2}])
result = await FCMClient(MagicMock(), "proj-123").simulate(request)
assert result.stability.converged is True
@pytest.mark.asyncio
async def test_rank_actions(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMRankActionsRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"goal": {"node_id": "g1", "label": "g1"},
"recommendations": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/rank-actions" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMRankActionsRequest(goal="g1")
result = await FCMClient(MagicMock(), "proj-123").rank_actions(request)
assert result.goal.node_id == "g1"
@pytest.mark.asyncio
async def test_import_model(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMImportRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"import_id": "imp-1",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": [],
"errors": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/import" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMImportRequest(source="/tmp/model.csv")
result = await FCMClient(MagicMock(), "proj-123").import_model(request)
assert result.import_id == "imp-1"
@pytest.mark.asyncio
async def test_export_model(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMExportRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"export_id": "exp-1",
"format": "csv_bundle_v1",
"files": [
{"name": "nodes.csv", "path": "/tmp/nodes.csv"},
{"name": "edges.csv", "path": "/tmp/edges.csv"},
],
"node_count": 0,
"edge_count": 0,
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/export" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMExportRequest()
result = await FCMClient(MagicMock(), "proj-123").export_model(request)
assert result.format == "csv_bundle_v1"
-32
View File
@@ -35,31 +35,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"expected_replacements",
"output_format",
],
"fcm_export_model": ["format", "selection", "project", "workspace", "output_format"],
"fcm_import_model": ["source", "format", "merge_mode", "project", "workspace", "output_format"],
"fcm_rank_actions": ["goal", "constraints", "top_k", "project", "workspace", "output_format"],
"fcm_simulate": ["actions", "scenario", "clamp_rules", "project", "workspace", "output_format"],
"fetch": ["id"],
"graph_health": ["scope", "timeframe", "project", "workspace", "output_format"],
"graph_impact": [
"target",
"horizon",
"relation_filters",
"include_reasons",
"project",
"workspace",
"output_format",
],
"graph_lineage": [
"start",
"goal",
"max_hops",
"relation_filters",
"project",
"workspace",
"output_format",
],
"graph_reindex": ["mode", "reason", "project", "workspace", "output_format"],
"list_directory": ["dir_name", "depth", "file_name_glob", "project", "workspace"],
"list_memory_projects": ["output_format", "workspace"],
"list_workspaces": ["output_format"],
@@ -137,15 +113,7 @@ TOOL_FUNCTIONS: dict[str, object] = {
"delete_note": tools.delete_note,
"delete_project": tools.delete_project,
"edit_note": tools.edit_note,
"fcm_export_model": tools.fcm_export_model,
"fcm_import_model": tools.fcm_import_model,
"fcm_rank_actions": tools.fcm_rank_actions,
"fcm_simulate": tools.fcm_simulate,
"fetch": tools.fetch,
"graph_health": tools.graph_health,
"graph_impact": tools.graph_impact,
"graph_lineage": tools.graph_lineage,
"graph_reindex": tools.graph_reindex,
"list_directory": tools.list_directory,
"list_memory_projects": tools.list_memory_projects,
"list_workspaces": tools.list_workspaces,
+27 -3
View File
@@ -1,6 +1,10 @@
"""Tests for delete_note MCP tool."""
from basic_memory.mcp.tools.delete_note import _format_delete_error_response
import pytest
from basic_memory.mcp.tools.delete_note import delete_note, _format_delete_error_response
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
class TestDeleteNoteErrorFormatting:
@@ -94,5 +98,25 @@ class TestDeleteNoteErrorFormatting:
assert "folder/note-title" in result # Permalink format
# Integration tests removed to focus on error formatting coverage
# The error formatting tests above provide the necessary coverage for MCP tool error messaging
@pytest.mark.asyncio
async def test_delete_note_rejects_fuzzy_match(client, test_project):
"""delete_note must reject nonexistent identifiers, not fuzzy-match to a similar note."""
await write_note(
project=test_project.name,
title="Delete Target Note",
directory="test",
content="# Delete Target Note\nShould not be deleted.",
)
# Attempt to delete a nonexistent note — should return False, not silently delete the existing note
result = await delete_note(
project=test_project.name,
identifier="Delete Target NONEXISTENT",
)
# Should indicate not found (False or error string)
assert result is False or (isinstance(result, str) and "not found" in result.lower())
# Verify the existing note was NOT deleted
content = await read_note("Delete Target Note", project=test_project.name)
assert "Should not be deleted" in content
+160 -1
View File
@@ -1,8 +1,10 @@
"""Tests for the edit_note MCP tool."""
import pytest
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
@@ -320,7 +322,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
content="new content",
)
assert "section parameter is required for replace_section operation" in str(exc_info.value)
assert "section parameter is required for section-based operations" in str(exc_info.value)
@pytest.mark.asyncio
@@ -611,3 +613,160 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
assert f"permalink: {test_project.name}/test/test-note" in second_result
assert f"[Session: Using project '{test_project.name}']" in second_result
# The edit should succeed without validation errors
@pytest.mark.asyncio
async def test_edit_note_find_replace_rejects_fuzzy_match(client, test_project):
"""find_replace must reject nonexistent identifiers, not fuzzy-match to a similar note."""
# Create two notes that could be fuzzy-matched
await write_note(
project=test_project.name,
title="Routing Test A",
directory="test",
content="# Routing Test A\nContent A.",
)
await write_note(
project=test_project.name,
title="Routing Test B",
directory="test",
content="# Routing Test B\nContent B.",
)
# Attempt to edit a nonexistent note — should error, not silently edit A or B
result = await edit_note(
project=test_project.name,
identifier="Routing Test NONEXISTENT",
operation="find_replace",
content="replaced",
find_text="Content",
)
assert isinstance(result, str)
assert "# Edit Failed" in result
# Verify neither A nor B was modified
content_a = await read_note("Routing Test A", project=test_project.name)
assert "Content A" in content_a
content_b = await read_note("Routing Test B", project=test_project.name)
assert "Content B" in content_b
@pytest.mark.asyncio
async def test_edit_note_append_autocreate_not_fuzzy_match(client, test_project):
"""append to a nonexistent note should auto-create it, not fuzzy-match an existing note."""
await write_note(
project=test_project.name,
title="Existing Note Alpha",
directory="test",
content="# Existing Note Alpha\nOriginal content.",
)
# Append to a nonexistent note — should create a new note, not edit "Existing Note Alpha"
result = await edit_note(
project=test_project.name,
identifier="Existing Note ZZZZZ",
operation="append",
content="# New Note\nBrand new content.",
)
assert isinstance(result, str)
assert "Created note (append)" in result
assert "fileCreated: true" in result
# Verify original note was NOT modified
content = await read_note("Existing Note Alpha", project=test_project.name)
assert "Original content" in content
assert "Brand new content" not in content
@pytest.mark.asyncio
async def test_edit_note_insert_before_section_operation(client, test_project):
"""Test inserting content before a section heading."""
# Create initial note with sections
await write_note(
project=test_project.name,
title="Insert Before Doc",
directory="docs",
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
)
result = await edit_note(
project=test_project.name,
identifier="docs/insert-before-doc",
operation="insert_before_section",
content="--- inserted divider ---",
section="## Details",
)
assert isinstance(result, str)
assert "Edited note (insert_before_section)" in result
assert f"project: {test_project.name}" in result
assert "Inserted content before section '## Details'" in result
assert f"[Session: Using project '{test_project.name}']" in result
@pytest.mark.asyncio
async def test_edit_note_insert_after_section_operation(client, test_project):
"""Test inserting content after a section heading."""
# Create initial note with sections
await write_note(
project=test_project.name,
title="Insert After Doc",
directory="docs",
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
)
result = await edit_note(
project=test_project.name,
identifier="docs/insert-after-doc",
operation="insert_after_section",
content="Inserted after overview heading",
section="## Overview",
)
assert isinstance(result, str)
assert "Edited note (insert_after_section)" in result
assert f"project: {test_project.name}" in result
assert "Inserted content after section '## Overview'" in result
assert f"[Session: Using project '{test_project.name}']" in result
@pytest.mark.asyncio
async def test_edit_note_insert_before_section_missing_section(client, test_project):
"""Test insert_before_section without section parameter raises ValueError."""
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError, match="section parameter is required"):
await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="insert_before_section",
content="new content",
)
@pytest.mark.asyncio
async def test_edit_note_insert_before_section_not_found(client, test_project):
"""Test insert_before_section when section doesn't exist returns error."""
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
content="# Test\n\n## Existing\nContent here.",
)
result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="insert_before_section",
content="new content",
section="## Nonexistent",
)
assert isinstance(result, str)
assert "# Edit Failed" in result
-114
View File
@@ -1,114 +0,0 @@
"""Tests for graph intelligence MCP tools."""
import pytest
from basic_memory.mcp.tools import (
fcm_export_model,
fcm_import_model,
fcm_rank_actions,
fcm_simulate,
graph_health,
graph_impact,
graph_lineage,
graph_reindex,
)
@pytest.mark.asyncio
async def test_graph_lineage_json_and_text_modes(app, test_project):
json_result = await graph_lineage(
start="memory://specs/search",
project=test_project.name,
output_format="json",
)
assert isinstance(json_result, dict)
assert set(["root", "paths", "generated_at"]).issubset(json_result.keys())
text_result = await graph_lineage(
start="memory://specs/search",
project=test_project.name,
output_format="text",
)
assert isinstance(text_result, str)
assert "Graph Lineage" in text_result
@pytest.mark.asyncio
async def test_graph_impact_and_health(app, test_project):
impact = await graph_impact(
target="memory://specs/search",
horizon=2,
project=test_project.name,
output_format="json",
)
assert isinstance(impact, dict)
assert set(["target", "affected", "summary"]).issubset(impact.keys())
health = await graph_health(
scope="specs",
timeframe="30d",
project=test_project.name,
output_format="json",
)
assert isinstance(health, dict)
assert set(["metrics", "issues", "computed_at"]).issubset(health.keys())
@pytest.mark.asyncio
async def test_graph_reindex(app, test_project):
result = await graph_reindex(project=test_project.name, output_format="json")
assert isinstance(result, dict)
assert result["status"] == "queued"
@pytest.mark.asyncio
async def test_fcm_simulate_and_rank_actions(app, test_project):
simulation = await fcm_simulate(
actions=[{"node_id": "n1", "delta": 0.2}],
project=test_project.name,
output_format="json",
)
assert isinstance(simulation, dict)
assert set(["baseline", "projected", "deltas", "stability", "confidence"]).issubset(
simulation.keys()
)
ranking = await fcm_rank_actions(
goal="reduce-regressions",
top_k=2,
project=test_project.name,
output_format="json",
)
assert isinstance(ranking, dict)
assert set(["goal", "recommendations"]).issubset(ranking.keys())
assert len(ranking["recommendations"]) <= 2
@pytest.mark.asyncio
async def test_fcm_import_export_json_and_text(app, test_project):
imported = await fcm_import_model(
source="/tmp/model.csv",
format="csv_bundle_v1",
project=test_project.name,
output_format="json",
)
assert isinstance(imported, dict)
assert "import_id" in imported
exported_json = await fcm_export_model(
format="csv_bundle_v1",
selection={"scope": "all"},
project=test_project.name,
output_format="json",
)
assert isinstance(exported_json, dict)
assert set(["export_id", "files", "node_count", "edge_count"]).issubset(exported_json.keys())
exported_text = await fcm_export_model(
format="csv_bundle_v1",
selection={"scope": "all"},
project=test_project.name,
output_format="text",
)
assert isinstance(exported_text, str)
assert "FCM Export" in exported_text
+25
View File
@@ -590,6 +590,31 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
assert "Content with custom metadata" in content
@pytest.mark.asyncio
async def test_move_note_rejects_fuzzy_match(client, test_project):
"""move_note must reject nonexistent identifiers, not fuzzy-match to a similar note."""
await write_note(
project=test_project.name,
title="Move Target Note",
directory="source",
content="# Move Target Note\nShould not be moved.",
)
# Attempt to move a nonexistent note — should error, not silently move the existing note
result = await move_note(
project=test_project.name,
identifier="Move Target NONEXISTENT",
destination_path="target/Moved.md",
)
assert isinstance(result, str)
assert "# Move Failed" in result
# Verify the existing note was NOT moved
content = await read_note("Move Target Note", project=test_project.name)
assert "Should not be moved" in content
class TestMoveNoteErrorFormatting:
"""Test move note error formatting for better user experience."""
+48
View File
@@ -1146,6 +1146,54 @@ async def test_search_notes_explicit_entity_types_overrides_default(monkeypatch)
assert captured_payload["entity_types"] == ["observation"]
# --- Tests for note_types case-insensitivity ------------------------------------
@pytest.mark.asyncio
async def test_search_notes_note_types_lowercased(monkeypatch):
"""note_types values are lowercased so 'Chapter' matches stored 'chapter'."""
import importlib
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
class StubProject:
name = "test-project"
external_id = "test-external-id"
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
captured_payload: dict = {}
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, payload, page, page_size):
captured_payload.update(payload)
return SearchResponse(results=[], current_page=page, page_size=page_size)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
note_types=["Chapter", "Person"],
)
# note_types should be lowercased
assert captured_payload["note_types"] == ["chapter", "person"]
# --- Tests for tag: prefix parsing (issue #30) ---------------------------------
@@ -237,6 +237,29 @@ class TestBuildChunkRecords:
records = self.repo._build_chunk_records(rows)
assert any("99" in r["chunk_key"] for r in records)
def test_duplicate_rows_collapse_to_unique_chunk_keys(self):
rows = [
_make_row(
row_type=SearchItemType.ENTITY.value,
title="Spec",
permalink="spec",
content_snippet="shared content",
row_id=77,
),
_make_row(
row_type=SearchItemType.ENTITY.value,
title="Spec",
permalink="spec",
content_snippet="shared content",
row_id=77,
),
]
records = self.repo._build_chunk_records(rows)
assert len(records) == 1
assert records[0]["chunk_key"] == "entity:77:0"
# --- SQLite SemanticSearchDisabledError ---
+43 -3
View File
@@ -345,7 +345,7 @@ def test_edit_entity_request_find_replace_empty_find_text():
def test_edit_entity_request_replace_section_empty_section():
"""Test that replace_section operation requires non-empty section parameter."""
with pytest.raises(
ValueError, match="section parameter is required for replace_section operation"
ValueError, match="section parameter is required for section-based operations"
):
EditEntityRequest.model_validate(
{
@@ -356,6 +356,46 @@ def test_edit_entity_request_replace_section_empty_section():
)
def test_edit_entity_request_insert_before_section():
"""Test insert_before_section is a valid operation."""
edit_request = EditEntityRequest.model_validate(
{
"operation": "insert_before_section",
"content": "content to insert",
"section": "## Target Section",
}
)
assert edit_request.operation == "insert_before_section"
assert edit_request.section == "## Target Section"
def test_edit_entity_request_insert_after_section():
"""Test insert_after_section is a valid operation."""
edit_request = EditEntityRequest.model_validate(
{
"operation": "insert_after_section",
"content": "content to insert",
"section": "## Target Section",
}
)
assert edit_request.operation == "insert_after_section"
assert edit_request.section == "## Target Section"
def test_edit_entity_request_insert_before_section_empty_section():
"""Test that insert_before_section requires non-empty section parameter."""
with pytest.raises(
ValueError, match="section parameter is required for section-based operations"
):
EditEntityRequest.model_validate(
{
"operation": "insert_before_section",
"content": "content",
"section": "",
}
)
# New tests for timeframe parsing functions
class TestTimeframeParsing:
"""Test cases for parse_timeframe() and validate_timeframe() functions."""
@@ -391,7 +431,7 @@ class TestTimeframeParsing:
result_1d = parse_timeframe("1d")
expected_1d = now - timedelta(days=1)
diff = abs((result_1d - expected_1d).total_seconds())
assert diff < 3600 # Within 1 hour tolerance (accounts for DST transitions)
assert diff <= 3610 # Within 1 hour tolerance + execution margin (DST transitions)
assert result_1d.tzinfo is not None
# Test yesterday - should be yesterday at same time
@@ -404,7 +444,7 @@ class TestTimeframeParsing:
result_week = parse_timeframe("1 week ago")
expected_week = now - timedelta(weeks=1)
diff = abs((result_week - expected_week).total_seconds())
assert diff < 3600 # Within 1 hour tolerance
assert diff <= 3610 # Within 1 hour tolerance + execution margin (DST transitions)
assert result_week.tzinfo is not None
def test_parse_timeframe_invalid(self):
+261
View File
@@ -1402,6 +1402,267 @@ async def test_edit_entity_replace_section_strips_duplicate_header(
assert "## Another Section" in file_content # Other sections preserved
# Insert before/after section tests
@pytest.mark.asyncio
async def test_edit_entity_insert_before_section(
entity_service: EntityService, file_service: FileService
):
"""Test inserting content before a section heading."""
content = dedent("""
# Main Title
## Section 1
Section 1 content
## Section 2
Section 2 content
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Insert Before Test",
directory="docs",
note_type="note",
content=content,
)
)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_before_section",
content="Inserted before section 2",
section="## Section 2",
)
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
assert "Inserted before section 2" in file_content
assert "## Section 2" in file_content
assert "Section 2 content" in file_content
# Inserted content should appear before the section heading
assert file_content.index("Inserted before section 2") < file_content.index("## Section 2")
@pytest.mark.asyncio
async def test_edit_entity_insert_after_section(
entity_service: EntityService, file_service: FileService
):
"""Test inserting content after a section heading."""
content = dedent("""
# Main Title
## Section 1
Section 1 content
## Section 2
Section 2 content
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Insert After Test",
directory="docs",
note_type="note",
content=content,
)
)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_after_section",
content="Inserted after section 1 heading",
section="## Section 1",
)
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
assert "Inserted after section 1 heading" in file_content
assert "## Section 1" in file_content
assert "Section 1 content" in file_content
# Inserted content should appear after the heading but content is also preserved
assert file_content.index("## Section 1") < file_content.index(
"Inserted after section 1 heading"
)
@pytest.mark.asyncio
async def test_edit_entity_insert_before_section_not_found(entity_service: EntityService):
"""Test insert_before_section raises ValueError when section not found."""
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
directory="test",
note_type="note",
content="# Main Title\n\nSome content",
)
)
with pytest.raises(ValueError, match="Section '## Missing' not found"):
await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_before_section",
content="new content",
section="## Missing",
)
@pytest.mark.asyncio
async def test_edit_entity_insert_after_section_not_found(entity_service: EntityService):
"""Test insert_after_section raises ValueError when section not found."""
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
directory="test",
note_type="note",
content="# Main Title\n\nSome content",
)
)
with pytest.raises(ValueError, match="Section '## Missing' not found"):
await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_after_section",
content="new content",
section="## Missing",
)
@pytest.mark.asyncio
async def test_edit_entity_insert_before_section_multiple_sections_error(
entity_service: EntityService,
):
"""Test insert_before_section raises ValueError with duplicate sections."""
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
directory="test",
note_type="note",
content="# Title\n\n## Dup\nFirst\n\n## Dup\nSecond",
)
)
with pytest.raises(ValueError, match="Multiple sections found"):
await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_before_section",
content="new content",
section="## Dup",
)
@pytest.mark.asyncio
async def test_edit_entity_insert_before_section_missing_section_param(
entity_service: EntityService,
):
"""Test insert_before_section raises ValueError when section param is missing."""
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
directory="test",
note_type="note",
content="# Title\n\nContent",
)
)
with pytest.raises(ValueError, match="section is required"):
await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_before_section",
content="new content",
)
@pytest.mark.asyncio
async def test_edit_entity_insert_before_section_empty_section(entity_service: EntityService):
"""Test insert_before_section raises ValueError when section is empty/whitespace."""
entity = await entity_service.create_entity(
EntitySchema(
title="Test Note",
directory="test",
note_type="note",
content="# Title\n\nContent",
)
)
with pytest.raises(ValueError, match="section cannot be empty"):
await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_before_section",
content="new content",
section=" ",
)
@pytest.mark.asyncio
async def test_edit_entity_insert_after_section_at_end_of_document(
entity_service: EntityService, file_service: FileService
):
"""Test inserting after the last section in a document."""
content = dedent("""
# Main Title
## Only Section
Some content here
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Insert End Test",
directory="docs",
note_type="note",
content=content,
)
)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_after_section",
content="Inserted after the last section heading",
section="## Only Section",
)
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
assert "Inserted after the last section heading" in file_content
assert "## Only Section" in file_content
assert "Some content here" in file_content
@pytest.mark.asyncio
async def test_edit_entity_insert_after_section_preserves_paragraph_separation(
entity_service: EntityService, file_service: FileService
):
"""Test that insert_after_section adds blank line so inserted text doesn't merge
with existing section content into a single markdown paragraph."""
content = dedent("""
# Main Title
## Section
Existing paragraph text
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Paragraph Sep Test",
directory="docs",
note_type="note",
content=content,
)
)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="insert_after_section",
content="Inserted line",
section="## Section",
)
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
# The inserted line and existing content should be separated by a blank line
assert "Inserted line\n\nExisting paragraph text" in file_content
# Move entity tests
@pytest.mark.asyncio
async def test_move_entity_success(
+68 -24
View File
@@ -200,10 +200,10 @@ async def test_initialize_app_no_precedence_warning_when_not_conflicting(
@pytest.mark.asyncio
async def test_run_migrations_triggers_embedding_backfill_on_new_revision(
async def test_run_migrations_triggers_embedding_backfill_when_entities_exist_but_no_embeddings(
monkeypatch, app_config: BasicMemoryConfig
):
"""When the trigger revision is newly applied, run automatic embedding backfill once."""
"""run_migrations checks for missing embeddings (actual backfill runs in background from MCP)."""
class StubSearchRepository:
def __init__(self, *args, **kwargs):
@@ -224,29 +224,24 @@ async def test_run_migrations_triggers_embedding_backfill_on_new_revision(
monkeypatch.setattr("basic_memory.db.SQLiteSearchRepository", StubSearchRepository)
monkeypatch.setattr("basic_memory.db.PostgresSearchRepository", StubSearchRepository)
load_revisions_mock = AsyncMock(
side_effect=[
set(),
{db.SEMANTIC_EMBEDDING_BACKFILL_REVISION},
]
needs_backfill_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"basic_memory.db._needs_semantic_embedding_backfill", needs_backfill_mock
)
backfill_mock = AsyncMock()
monkeypatch.setattr("basic_memory.db._load_applied_alembic_revisions", load_revisions_mock)
monkeypatch.setattr("basic_memory.db._run_semantic_embedding_backfill", backfill_mock)
await db.run_migrations(app_config)
assert load_revisions_mock.await_count == 2
backfill_mock.assert_awaited_once_with(app_config, session_marker)
# Verifies the check runs — backfill itself is launched by MCP lifespan
needs_backfill_mock.assert_awaited_once_with(app_config, session_marker)
finally:
db._session_maker = original_session_maker # pyright: ignore [reportPrivateUsage]
@pytest.mark.asyncio
async def test_run_migrations_skips_embedding_backfill_when_revision_already_applied(
async def test_run_migrations_skips_embedding_backfill_when_embeddings_already_exist(
monkeypatch, app_config: BasicMemoryConfig
):
"""If the trigger revision was already present before upgrade, skip backfill."""
"""When embeddings already exist, no backfill is needed."""
class StubSearchRepository:
def __init__(self, *args, **kwargs):
@@ -267,20 +262,14 @@ async def test_run_migrations_skips_embedding_backfill_when_revision_already_app
monkeypatch.setattr("basic_memory.db.SQLiteSearchRepository", StubSearchRepository)
monkeypatch.setattr("basic_memory.db.PostgresSearchRepository", StubSearchRepository)
load_revisions_mock = AsyncMock(
side_effect=[
{db.SEMANTIC_EMBEDDING_BACKFILL_REVISION},
{db.SEMANTIC_EMBEDDING_BACKFILL_REVISION},
]
needs_backfill_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
"basic_memory.db._needs_semantic_embedding_backfill", needs_backfill_mock
)
backfill_mock = AsyncMock()
monkeypatch.setattr("basic_memory.db._load_applied_alembic_revisions", load_revisions_mock)
monkeypatch.setattr("basic_memory.db._run_semantic_embedding_backfill", backfill_mock)
await db.run_migrations(app_config)
assert load_revisions_mock.await_count == 2
assert backfill_mock.await_count == 0
needs_backfill_mock.assert_awaited_once_with(app_config, session_marker)
finally:
db._session_maker = original_session_maker # pyright: ignore [reportPrivateUsage]
@@ -378,3 +367,58 @@ async def test_semantic_embedding_backfill_skips_when_semantic_disabled(
app_config.semantic_search_enabled = False
await db._run_semantic_embedding_backfill(app_config, session_maker) # pyright: ignore [reportPrivateUsage]
assert called is False
@pytest.mark.asyncio
async def test_needs_semantic_embedding_backfill_true_when_entities_exist_no_embeddings(
app_config: BasicMemoryConfig,
session_maker,
test_project,
):
"""Should return True when entities exist but vector chunks table is empty."""
from basic_memory.repository.entity_repository import EntityRepository
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
await entity_repository.create(
{
"title": "Test Entity",
"note_type": "note",
"entity_metadata": {},
"content_type": "text/markdown",
"file_path": "test/backfill-check.md",
"permalink": "test/backfill-check",
"project_id": test_project.id,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
)
# Clear any embeddings left by other tests in the shared DB
async with db.scoped_session(session_maker) as session:
await session.execute(db.text("DELETE FROM search_vector_chunks"))
app_config.semantic_search_enabled = True
result = await db._needs_semantic_embedding_backfill(app_config, session_maker) # pyright: ignore [reportPrivateUsage]
assert result is True
@pytest.mark.asyncio
async def test_needs_semantic_embedding_backfill_false_when_no_entities(
app_config: BasicMemoryConfig,
session_maker,
):
"""Should return False when no entities exist (nothing to backfill)."""
app_config.semantic_search_enabled = True
result = await db._needs_semantic_embedding_backfill(app_config, session_maker) # pyright: ignore [reportPrivateUsage]
assert result is False
@pytest.mark.asyncio
async def test_needs_semantic_embedding_backfill_false_when_semantic_disabled(
app_config: BasicMemoryConfig,
session_maker,
):
"""Should return False when semantic search is disabled."""
app_config.semantic_search_enabled = False
result = await db._needs_semantic_embedding_backfill(app_config, session_maker) # pyright: ignore [reportPrivateUsage]
assert result is False
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
from sqlalchemy import text
from sqlalchemy.exc import OperationalError as SAOperationalError
from basic_memory.schemas.project_info import EmbeddingStatus
from basic_memory.services.project_service import ProjectService
@@ -142,6 +143,46 @@ async def test_embedding_status_orphaned_chunks(
assert "orphaned chunks" in (status.reindex_reason or "")
@pytest.mark.asyncio
async def test_embedding_status_handles_sqlite_vec_unavailable(
project_service: ProjectService, test_graph, test_project
):
"""Unreadable vec0 tables should degrade to unavailable status instead of crashing."""
# Trigger: Postgres test matrix executes the same unit suite.
# Why: sqlite-vec loading failures are specific to SQLite virtual tables, not Postgres joins.
# Outcome: keep the regression focused on the backend that can actually hit this path.
if _is_postgres():
pytest.skip("sqlite-vec unavailable handling is SQLite-specific.")
original_execute_query = project_service.repository.execute_query
async def _execute_query_with_vec0_failure(query, params):
query_text = str(query)
if "JOIN search_vector_embeddings" in query_text:
raise SAOperationalError(query_text, params, Exception("no such module: vec0"))
return await original_execute_query(query, params)
with patch.object(
type(project_service),
"config_manager",
new_callable=lambda: property(
lambda self: _config_manager_with(semantic_search_enabled=True)
),
):
with patch.object(
project_service.repository,
"execute_query",
side_effect=_execute_query_with_vec0_failure,
):
status = await project_service.get_embedding_status(test_project.id)
assert status.semantic_search_enabled is True
assert status.total_indexed_entities > 0
assert status.vector_tables_exist is False
assert status.reindex_recommended is True
assert "sqlite-vec is unavailable" in (status.reindex_reason or "")
@pytest.mark.asyncio
async def test_embedding_status_healthy(project_service: ProjectService, test_graph, test_project):
"""When all entities have embeddings, no reindex recommended."""
+58
View File
@@ -0,0 +1,58 @@
"""Tests for coerce_list and coerce_dict utility functions.
These must fail until the helpers are implemented in utils.py.
"""
from basic_memory.utils import coerce_list, coerce_dict
class TestCoerceList:
"""Tests for coerce_list."""
def test_none_passthrough(self):
assert coerce_list(None) is None
def test_native_list_passthrough(self):
assert coerce_list(["a", "b"]) == ["a", "b"]
def test_json_array_string(self):
assert coerce_list('["entity", "observation"]') == ["entity", "observation"]
def test_single_string_wrapped(self):
assert coerce_list("entity") == ["entity"]
def test_non_json_string_wrapped(self):
assert coerce_list("not-json") == ["not-json"]
def test_json_object_string_wrapped(self):
"""A JSON object string is not a list, so wrap it."""
assert coerce_list('{"key": "val"}') == ['{"key": "val"}']
def test_int_passthrough(self):
"""Non-string, non-None values pass through unchanged."""
assert coerce_list(42) == 42
class TestCoerceDict:
"""Tests for coerce_dict."""
def test_none_passthrough(self):
assert coerce_dict(None) is None
def test_native_dict_passthrough(self):
assert coerce_dict({"k": "v"}) == {"k": "v"}
def test_json_object_string(self):
assert coerce_dict('{"status": "draft"}') == {"status": "draft"}
def test_non_json_string_passthrough(self):
"""Non-parseable strings pass through (Pydantic will reject them)."""
assert coerce_dict("not-json") == "not-json"
def test_json_array_string_passthrough(self):
"""A JSON array string is not a dict, so pass through."""
assert coerce_dict('["a", "b"]') == '["a", "b"]'
def test_int_passthrough(self):
assert coerce_dict(42) == 42
+162
View File
@@ -0,0 +1,162 @@
"""Tests for logging setup helpers."""
import os
import sys
from basic_memory import utils
def test_setup_logging_uses_shared_log_file_off_windows(monkeypatch, tmp_path) -> None:
"""Non-Windows platforms should keep the shared log filename."""
added_sinks: list[str] = []
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setattr(utils.os, "name", "posix")
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(
utils.logger,
"add",
lambda sink, **kwargs: added_sinks.append(str(sink)),
)
utils.setup_logging(log_to_file=True)
assert added_sinks == [str(tmp_path / ".basic-memory" / "basic-memory.log")]
def test_setup_logging_uses_per_process_log_file_on_windows(monkeypatch, tmp_path) -> None:
"""Windows uses per-process logs so rotation never contends across processes."""
added_sinks: list[str] = []
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setattr(utils.os, "name", "nt")
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(
utils.logger,
"add",
lambda sink, **kwargs: added_sinks.append(str(sink)),
)
utils.setup_logging(log_to_file=True)
assert added_sinks == [str(tmp_path / ".basic-memory" / "basic-memory-4242.log")]
def test_setup_logging_trims_stale_windows_pid_logs(monkeypatch, tmp_path) -> None:
"""Windows cleanup should bound stale PID-specific log files across runs."""
log_dir = tmp_path / ".basic-memory"
log_dir.mkdir()
stale_logs = []
for index in range(6):
log_path = log_dir / f"basic-memory-{1000 + index}.log"
log_path.write_text("old log", encoding="utf-8")
mtime = 1_000 + index
os.utime(log_path, (mtime, mtime))
stale_logs.append(log_path)
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setattr(utils.os, "name", "nt")
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(utils.logger, "add", lambda *args, **kwargs: None)
utils.setup_logging(log_to_file=True)
remaining = sorted(path.name for path in log_dir.glob("basic-memory-*.log*"))
assert remaining == [
"basic-memory-1002.log",
"basic-memory-1003.log",
"basic-memory-1004.log",
"basic-memory-1005.log",
]
def test_setup_logging_test_env_uses_stderr_only(monkeypatch) -> None:
"""Test mode should add one stderr sink and return before other branches run."""
added_sinks: list[object] = []
configured_calls: list[dict] = []
monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(utils.logger, "add", lambda sink, **kwargs: added_sinks.append(sink))
monkeypatch.setattr(
utils.logger,
"configure",
lambda **kwargs: configured_calls.append(kwargs),
)
utils.setup_logging(log_to_file=True, log_to_stdout=True, structured_context=True)
assert added_sinks == [sys.stderr]
assert configured_calls == []
def test_setup_logging_log_to_stdout(monkeypatch) -> None:
"""stdout logging should attach a stderr sink outside test mode."""
added_sinks: list[object] = []
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(utils.logger, "add", lambda sink, **kwargs: added_sinks.append(sink))
utils.setup_logging(log_to_stdout=True)
assert added_sinks == [sys.stderr]
def test_setup_logging_structured_context(monkeypatch) -> None:
"""Structured context should bind cloud metadata into loguru extras."""
configured_extras: list[dict[str, str]] = []
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setenv("BASIC_MEMORY_TENANT_ID", "tenant-123")
monkeypatch.setenv("FLY_APP_NAME", "bm-app")
monkeypatch.setenv("FLY_MACHINE_ID", "machine-123")
monkeypatch.setenv("FLY_REGION", "ord")
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(utils.logger, "add", lambda *args, **kwargs: None)
monkeypatch.setattr(
utils.logger,
"configure",
lambda **kwargs: configured_extras.append(kwargs["extra"]),
)
utils.setup_logging(structured_context=True)
assert configured_extras == [
{
"tenant_id": "tenant-123",
"fly_app_name": "bm-app",
"fly_machine_id": "machine-123",
"fly_region": "ord",
}
]
def test_setup_logging_suppresses_noisy_loggers(monkeypatch) -> None:
"""Third-party HTTP/file-watch loggers should be raised to WARNING."""
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
monkeypatch.setattr(utils.logger, "add", lambda *args, **kwargs: None)
httpx_logger = utils.logging.getLogger("httpx")
watchfiles_logger = utils.logging.getLogger("watchfiles.main")
original_httpx_level = httpx_logger.level
original_watchfiles_level = watchfiles_logger.level
try:
httpx_logger.setLevel(utils.logging.DEBUG)
watchfiles_logger.setLevel(utils.logging.INFO)
utils.setup_logging()
assert httpx_logger.level == utils.logging.WARNING
assert watchfiles_logger.level == utils.logging.WARNING
finally:
httpx_logger.setLevel(original_httpx_level)
watchfiles_logger.setLevel(original_watchfiles_level)
Generated
+9 -3
View File
@@ -142,14 +142,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.6"
version = "1.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
]
[[package]]
@@ -304,10 +304,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]