mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: fast edit entities, refactors for webui, enhance search (#532)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user