feat: fast edit entities, refactors for webui, enhance search (#532)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-01-31 15:16:52 -06:00
committed by GitHub
parent e3ced49d9d
commit 530cbac73f
85 changed files with 1849 additions and 7120 deletions
+23 -3
View File
@@ -43,7 +43,9 @@ class KnowledgeClient:
# --- Entity CRUD Operations ---
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
async def create_entity(
self, entity_data: dict[str, Any], *, fast: bool | None = None
) -> EntityResponse:
"""Create a new entity.
Args:
@@ -55,14 +57,22 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
params = {"fast": fast} if fast is not None else None
response = await call_post(
self.http_client,
f"{self._base_path}/entities",
json=entity_data,
params=params,
)
return EntityResponse.model_validate(response.json())
async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
async def update_entity(
self,
entity_id: str,
entity_data: dict[str, Any],
*,
fast: bool | None = None,
) -> EntityResponse:
"""Update an existing entity (full replacement).
Args:
@@ -75,10 +85,12 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
params = {"fast": fast} if fast is not None else None
response = await call_put(
self.http_client,
f"{self._base_path}/entities/{entity_id}",
json=entity_data,
params=params,
)
return EntityResponse.model_validate(response.json())
@@ -100,7 +112,13 @@ class KnowledgeClient:
)
return EntityResponse.model_validate(response.json())
async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
async def patch_entity(
self,
entity_id: str,
patch_data: dict[str, Any],
*,
fast: bool | None = None,
) -> EntityResponse:
"""Partially update an entity.
Args:
@@ -113,10 +131,12 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
params = {"fast": fast} if fast is not None else None
response = await call_patch(
self.http_client,
f"{self._base_path}/entities/{entity_id}",
json=patch_data,
params=params,
)
return EntityResponse.model_validate(response.json())
+2 -2
View File
@@ -47,7 +47,7 @@ class ProjectClient:
"""
response = await call_get(
self.http_client,
"/projects/projects",
"/v2/projects/",
)
return ProjectList.model_validate(response.json())
@@ -65,7 +65,7 @@ class ProjectClient:
"""
response = await call_post(
self.http_client,
"/projects/projects",
"/v2/projects/",
json=project_data,
)
return ProjectStatusResponse.model_validate(response.json())
+17 -6
View File
@@ -19,7 +19,7 @@ from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.project_resolver import ProjectResolver
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.utils import generate_permalink
from basic_memory.schemas.v2 import ProjectResolveResponse
async def resolve_project_parameter(
@@ -78,7 +78,7 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
# Deferred import to avoid circular dependency with tools
from basic_memory.mcp.tools.utils import call_get
response = await call_get(client, "/projects/projects", headers=headers)
response = await call_get(client, "/v2/projects/", headers=headers)
project_list = ProjectList.model_validate(response.json())
return [project.name for project in project_list.projects]
@@ -104,7 +104,7 @@ async def get_active_project(
HTTPError: If project doesn't exist or is inaccessible
"""
# Deferred import to avoid circular dependency with tools
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.tools.utils import call_post
resolved_project = await resolve_project_parameter(project)
if not resolved_project:
@@ -126,9 +126,20 @@ async def get_active_project(
# Validate project exists by calling API
logger.debug(f"Validating project: {project}")
permalink = generate_permalink(project)
response = await call_get(client, f"/{permalink}/project/item", headers=headers)
active_project = ProjectItem.model_validate(response.json())
response = await call_post(
client,
"/v2/projects/resolve",
json={"identifier": project},
headers=headers,
)
resolved = ProjectResolveResponse.model_validate(response.json())
active_project = ProjectItem(
id=resolved.project_id,
external_id=resolved.external_id,
name=resolved.name,
path=resolved.path,
is_default=resolved.is_default,
)
# Cache in context if available
if context:
@@ -9,8 +9,9 @@ from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.prompt import ContinueConversationRequest
@@ -42,17 +43,18 @@ async def continue_conversation(
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
async with get_client() as client:
config = ConfigManager().config
active_project = await get_active_project(client, project=config.default_project)
# Create request model
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
topic=topic, timeframe=timeframe
)
project_url = get_project_config().project_url
# Call the prompt API endpoint
response = await call_post(
client,
f"{project_url}/prompt/continue-conversation",
f"/v2/projects/{active_project.external_id}/prompt/continue-conversation",
json=request.model_dump(exclude_none=True),
)
@@ -46,9 +46,7 @@ async def recent_activity_prompt(
# Call the tool function - it returns a well-formatted string
# Pass type as string values (not enum) to match the tool's expected input
activity_summary = await recent_activity.fn(
project=project, timeframe=timeframe, type="entity"
)
activity_summary = await recent_activity.fn(project=project, timeframe=timeframe, type="entity")
# Build the prompt response
# The tool already returns formatted markdown, so we use it directly
@@ -92,7 +90,7 @@ async def recent_activity_prompt(
- summarizes [[Recent Work]]
''',
folder="insights",
project="{project or 'default'}"
project="{project or "default"}"
)
```
""")
+8 -4
View File
@@ -8,8 +8,9 @@ from typing import Annotated, Optional
from loguru import logger
from pydantic import Field
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.prompt import SearchPromptRequest
@@ -41,14 +42,17 @@ async def search_prompt(
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
async with get_client() as client:
config = ConfigManager().config
active_project = await get_active_project(client, project=config.default_project)
# Create request model
request = SearchPromptRequest(query=query, timeframe=timeframe)
project_url = get_project_config().project_url
# Call the prompt API endpoint
response = await call_post(
client, f"{project_url}/prompt/search", json=request.model_dump(exclude_none=True)
client,
f"/v2/projects/{active_project.external_id}/prompt/search",
json=request.model_dump(exclude_none=True),
)
# Extract the rendered prompt from the response
@@ -62,10 +62,9 @@ async def project_info(
async with get_client() as client:
project_config = await get_active_project(client, project, context)
project_url = project_config.permalink
# Call the API endpoint
response = await call_get(client, f"{project_url}/project/info")
response = await call_get(client, f"/v2/projects/{project_config.external_id}/info")
# Convert response to ProjectInfoResponse
return ProjectInfoResponse.model_validate(response.json())
+2 -1
View File
@@ -13,7 +13,7 @@ from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.view_note import view_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.search import search_notes, search_by_metadata
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
@@ -42,6 +42,7 @@ __all__ = [
"read_note",
"recent_activity",
"search",
"search_by_metadata",
"search_notes",
"view_note",
"write_note",
+1 -1
View File
@@ -256,7 +256,7 @@ async def edit_note(
edit_data["expected_replacements"] = str(expected_replacements)
# Call the PATCH endpoint
result = await knowledge_client.patch_entity(entity_id, edit_data)
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
# Format summary
summary = [
+3 -1
View File
@@ -488,7 +488,9 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
return "\n".join(result_lines)
except Exception as e: # pragma: no cover
logger.error(f"Directory move failed for '{identifier}' to '{destination_path}': {e}")
logger.error(
f"Directory move failed for '{identifier}' to '{destination_path}': {e}"
)
return f"""# Directory Move Failed
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
@@ -144,7 +144,7 @@ async def recent_activity(
)
# Get list of all projects
response = await call_get(client, "/projects/projects")
response = await call_get(client, "/v2/projects/")
project_list = ProjectList.model_validate(response.json())
projects_activity = {}
+113 -1
View File
@@ -1,7 +1,7 @@
"""Search tools for Basic Memory MCP server."""
from textwrap import dedent
from typing import List, Optional
from typing import List, Optional, Dict, Any
from loguru import logger
from fastmcp import Context
@@ -207,6 +207,9 @@ async def search_notes(
types: List[str] | None = None,
entity_types: List[str] | None = None,
after_date: Optional[str] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
status: Optional[str] = None,
context: Context | None = None,
) -> SearchResponse | str:
"""Search across all content in the knowledge base with comprehensive syntax support.
@@ -248,6 +251,27 @@ async def search_notes(
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
- `search_notes("my-project", "query", status="in-progress")` - Filter by frontmatter status
- `search_notes("my-project", "query", metadata_filters={"priority": {"$in": ["high"]}})`
### Structured Metadata Filters
Filters are exact matches on frontmatter metadata. Supported forms:
- Equality: `{"status": "in-progress"}`
- Array contains (all): `{"tags": ["security", "oauth"]}`
- Operators:
- `$in`: `{"priority": {"$in": ["high", "critical"]}}`
- `$gt`, `$gte`, `$lt`, `$lte`: `{"schema.confidence": {"$gt": 0.7}}`
- `$between`: `{"schema.confidence": {"$between": [0.3, 0.6]}}`
- Nested keys use dot notation (e.g., `"schema.confidence"`).
### Filter-only Searches
You can pass an empty query string when only using structured filters:
- `search_notes("my-project", "", metadata_filters={"type": "spec"})`
### Convenience Filters
`tags` and `status` are shorthand for metadata_filters. If the same key exists in
metadata_filters, that value wins.
### Advanced Pattern Examples
- `search_notes("work-project", "project AND (meeting OR discussion)")` - Complex boolean logic
@@ -265,6 +289,9 @@ async def search_notes(
types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
context: Optional FastMCP context for performance caching.
Returns:
@@ -355,6 +382,12 @@ async def search_notes(
search_query.types = types
if after_date:
search_query.after_date = after_date
if metadata_filters:
search_query.metadata_filters = metadata_filters
if tags:
search_query.tags = tags
if status:
search_query.status = status
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -387,3 +420,82 @@ async def search_notes(
logger.error(f"Search failed for query '{query}': {e}, project: {active_project.name}")
# Return formatted error message as string for better user experience
return _format_search_error_response(active_project.name, str(e), query, search_type)
@mcp.tool(
description="Search entities by structured frontmatter metadata.",
)
async def search_by_metadata(
filters: Dict[str, Any],
project: Optional[str] = None,
limit: int = 20,
offset: int = 0,
context: Context | None = None,
) -> SearchResponse | str:
"""Search entities by structured frontmatter metadata.
Args:
filters: Dictionary of metadata filters (e.g., {"status": "in-progress"})
project: Project name to search in. Optional - server will resolve using hierarchy.
limit: Maximum number of results to return
offset: Number of results to skip (for pagination)
context: Optional FastMCP context for performance caching.
Returns:
SearchResponse with results, or helpful error guidance if search fails
"""
if limit <= 0:
return "# Error\n\n`limit` must be greater than 0."
# Build a structured-only search query
search_query = SearchQuery()
search_query.metadata_filters = filters
search_query.entity_types = [SearchItemType.ENTITY]
# Convert offset/limit to page/page_size (API uses paging)
page_size = limit
page = (offset // limit) + 1
offset_within_page = offset % limit
async with get_client() as client:
active_project = await get_active_project(client, project, context)
logger.info(
f"Structured search in project {active_project.name} filters={filters} limit={limit} offset={offset}"
)
try:
from basic_memory.mcp.clients import SearchClient
search_client = SearchClient(client, active_project.external_id)
result = await search_client.search(
search_query.model_dump(),
page=page,
page_size=page_size,
)
# Apply offset within page, fetch next page if needed
if offset_within_page:
remaining = result.results[offset_within_page:]
if len(remaining) < limit:
next_page = page + 1
extra = await search_client.search(
search_query.model_dump(),
page=next_page,
page_size=page_size,
)
remaining.extend(extra.results[: max(0, limit - len(remaining))])
result = SearchResponse(
results=remaining[:limit],
current_page=page,
page_size=page_size,
)
return result
except Exception as e:
logger.error(
f"Metadata search failed for filters '{filters}': {e}, project: {active_project.name}"
)
return _format_search_error_response(
active_project.name, str(e), str(filters), "metadata"
)
+4 -2
View File
@@ -159,7 +159,7 @@ async def write_note(
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
action = "Created" # Default to created
try:
result = await knowledge_client.create_entity(entity.model_dump())
result = await knowledge_client.create_entity(entity.model_dump(), fast=False)
action = "Created"
except Exception as e:
# If creation failed due to conflict (already exists), try to update
@@ -175,7 +175,9 @@ async def write_note(
"Entity permalink is required for updates"
) # pragma: no cover
entity_id = await knowledge_client.resolve_entity(entity.permalink)
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
result = await knowledge_client.update_entity(
entity_id, entity.model_dump(), fast=False
)
action = "Updated"
except Exception as update_error: # pragma: no cover
# Re-raise the original error if update also fails