mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e4bb72f10 | |||
| 11b0e31e24 | |||
| a5c9e77f16 | |||
| 30a89357cb | |||
| 222ec5d3b6 | |||
| d42aec7ea9 | |||
| 9809b469c6 | |||
| 76ac880f2d | |||
| ad3f2650d9 | |||
| d6508d985c | |||
| 7b95b9f37b | |||
| 0bce4be1a6 | |||
| a316424edf | |||
| af71cf4896 | |||
| e846ae85d8 | |||
| 63e4bcdf1d |
+159
-4
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user