mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix tests for context and permalinks
This commit is contained in:
@@ -119,7 +119,7 @@ async def add_observations(
|
||||
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
|
||||
async def get_entity(
|
||||
entity_service: EntityServiceDep,
|
||||
permalink: PathId,
|
||||
permalink: str,
|
||||
) -> EntityResponse:
|
||||
"""Get a specific entity by ID.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ async def get_memory_context(
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: str = "7d",
|
||||
max_results: int = 10
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
@@ -46,7 +47,7 @@ async def get_memory_context(
|
||||
since = parse_timeframe(timeframe)
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(memory_url, depth=depth, since=since)
|
||||
context = await context_service.build_context(memory_url, depth=depth, since=since, max_results=max_results)
|
||||
|
||||
primary_entities = [SearchResult(**asdict(r)) for r in context["primary_entities"]]
|
||||
related_entities = [RelatedResult(**asdict(r)) for r in context["related_entities"]]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Base package for markdown parsing."""
|
||||
|
||||
from basic_memory.file_utils import ParseError
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.schemas import (
|
||||
EntityMarkdown,
|
||||
@@ -7,7 +9,6 @@ from basic_memory.markdown.schemas import (
|
||||
Observation,
|
||||
Relation,
|
||||
)
|
||||
from basic_memory.utils.file_utils import ParseError
|
||||
|
||||
__all__ = [
|
||||
"EntityMarkdown",
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
"""Enhanced FastMCP server instance for Basic Memory."""
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.utilities.logging import configure_logging
|
||||
|
||||
from basic_memory.config import config
|
||||
|
||||
# mcp console logging
|
||||
configure_logging(level="INFO")
|
||||
|
||||
|
||||
def setup_logging(home_dir: str = config.home, log_file: str = ".basic-memory/basic-memory.log"):
|
||||
"""Configure file logging to the basic-memory home directory."""
|
||||
log = f"{home_dir}/{log_file}"
|
||||
|
||||
# Add file handler with rotation
|
||||
logger.add(
|
||||
log,
|
||||
rotation="100 MB",
|
||||
retention="10 days",
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
enqueue=True,
|
||||
colorize=False,
|
||||
)
|
||||
|
||||
# Add stderr handler
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
# start our out file logging
|
||||
setup_logging()
|
||||
|
||||
# Create the shared server instance
|
||||
mcp = FastMCP("Basic Memory")
|
||||
|
||||
@@ -13,7 +13,7 @@ from basic_memory.mcp.tools import search # noqa: F401
|
||||
from basic_memory.mcp.tools.activity import (
|
||||
get_recent_activity,
|
||||
)
|
||||
from basic_memory.mcp.tools.discussion import build_context
|
||||
from basic_memory.mcp.tools.memory import build_context
|
||||
from basic_memory.mcp.tools.ai_edit import ai_edit
|
||||
|
||||
# Export the tools
|
||||
|
||||
@@ -14,8 +14,9 @@ from basic_memory.schemas.memory import GraphContext, MemoryUrl
|
||||
)
|
||||
async def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 2,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[str] = "7d",
|
||||
max_results: int = 10
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -28,6 +29,7 @@ async def build_context(
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (default: 2)
|
||||
timeframe: How far back to look, e.g. "7d", "24h" (default: "7d")
|
||||
max_results: The maximum number of results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -39,6 +41,6 @@ async def build_context(
|
||||
# Map directly to the memory endpoint
|
||||
memory_url = MemoryUrl.validate(url)
|
||||
response = await client.get(
|
||||
f"/memory/{memory_url.relative_path()}", params={"depth": depth, "timeframe": timeframe}
|
||||
f"/memory/{memory_url.relative_path()}", params={"depth": depth, "timeframe": timeframe, "max_results": max_results}
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
from mcp.server.fastmcp import Context
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.schemas.request import GetEntitiesRequest
|
||||
from basic_memory.schemas.response import EntityListResponse
|
||||
from basic_memory.mcp.async_client import client
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in basic-memory, including documents and entities",
|
||||
)
|
||||
async def search(ctx: Context, query: SearchQuery) -> SearchResponse:
|
||||
async def search(query: SearchQuery) -> SearchResponse:
|
||||
"""Search across all content in basic-memory.
|
||||
|
||||
Args:
|
||||
@@ -24,6 +22,6 @@ async def search(ctx: Context, query: SearchQuery) -> SearchResponse:
|
||||
Returns:
|
||||
SearchResponse with search results and metadata
|
||||
"""
|
||||
ctx.info(f"Searching for {query.text}")
|
||||
logger.info(f"Searching for {query.text}")
|
||||
response = await client.post("/search/", json=query.model_dump())
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
@@ -23,47 +23,6 @@ from basic_memory.models.base import Base
|
||||
from enum import Enum
|
||||
|
||||
|
||||
def generate_permalink(file_path: str) -> str:
|
||||
"""Generate a stable permalink from a file path.
|
||||
|
||||
Args:
|
||||
file_path: Original file path
|
||||
|
||||
Returns:
|
||||
Normalized permalink that matches validation rules. Converts spaces and underscores
|
||||
to hyphens for consistency.
|
||||
|
||||
Examples:
|
||||
>>> generate_permalink("docs/My Feature.md")
|
||||
'docs/my-feature'
|
||||
>>> generate_permalink("specs/API (v2).md")
|
||||
'specs/api-v2'
|
||||
>>> generate_permalink("design/unified_model_refactor.md")
|
||||
'design/unified-model-refactor'
|
||||
"""
|
||||
# Remove extension
|
||||
base = os.path.splitext(file_path)[0]
|
||||
|
||||
# Transliterate unicode to ascii
|
||||
ascii_text = unidecode(base)
|
||||
|
||||
# Convert to lowercase
|
||||
lower_text = ascii_text.lower()
|
||||
|
||||
# First replace underscores with hyphens
|
||||
text_with_hyphens = lower_text.replace('_', '-')
|
||||
|
||||
# Replace remaining invalid chars with hyphens
|
||||
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
|
||||
|
||||
# Collapse multiple hyphens
|
||||
clean_text = re.sub(r'-+', '-', clean_text)
|
||||
|
||||
# Clean each path segment
|
||||
segments = clean_text.split('/')
|
||||
clean_segments = [s.strip('-') for s in segments]
|
||||
|
||||
return '/'.join(clean_segments)
|
||||
|
||||
|
||||
class Entity(Base):
|
||||
|
||||
@@ -19,6 +19,8 @@ from typing import List, Optional, Annotated, Dict
|
||||
from annotated_types import MinLen, MaxLen
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator, ValidationError
|
||||
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
def to_snake_case(name: str) -> str:
|
||||
"""Convert a string to snake_case.
|
||||
@@ -79,7 +81,7 @@ class ObservationCategory(str, Enum):
|
||||
return None
|
||||
|
||||
|
||||
PathId = Annotated[str, BeforeValidator(to_snake_case), BeforeValidator(validate_path_format)]
|
||||
PathId = Annotated[str, BeforeValidator(validate_path_format)]
|
||||
"""Unique identifier in format '{path}/{normalized_name}'."""
|
||||
|
||||
Observation = Annotated[
|
||||
@@ -154,8 +156,7 @@ class Entity(BaseModel):
|
||||
@property
|
||||
def permalink(self) -> PathId:
|
||||
"""Get the path ID in format {snake_case_title}."""
|
||||
normalized_name = to_snake_case(self.title)
|
||||
return normalized_name
|
||||
return generate_permalink(self.title)
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
|
||||
@@ -56,7 +56,7 @@ class RelationResponse(Relation, SQLAlchemyModel):
|
||||
Example Response:
|
||||
{
|
||||
"from_id": "test/memory_test",
|
||||
"to_id": "component/memory_service",
|
||||
"to_id": "component/memory-service",
|
||||
"relation_type": "validates",
|
||||
"context": "Comprehensive test suite"
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"permalink": "component/memory_service",
|
||||
"permalink": "component/memory-service",
|
||||
"title": "MemoryService",
|
||||
"entity_type": "component",
|
||||
"description": "Core persistence service",
|
||||
@@ -109,8 +109,8 @@ class EntityResponse(SQLAlchemyModel):
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"from_id": "test/memory_test",
|
||||
"to_id": "component/memory_service",
|
||||
"from_id": "test/memory-test",
|
||||
"to_id": "component/memory-service",
|
||||
"relation_type": "validates",
|
||||
"context": "Main test suite"
|
||||
}
|
||||
@@ -181,7 +181,7 @@ class SearchNodesResponse(SQLAlchemyModel):
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"permalink": "component/memory_service",
|
||||
"permalink": "component/memory-service",
|
||||
"title": "MemoryService",
|
||||
"entity_type": "component",
|
||||
"description": "Core service",
|
||||
|
||||
@@ -98,7 +98,6 @@ class RelatedResult(BaseModel):
|
||||
relation_type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
entity_id: Optional[int] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
|
||||
@@ -27,7 +27,7 @@ class ContextResultRow:
|
||||
relation_type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
entity_id: Optional[int] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
class ContextService:
|
||||
@@ -50,8 +50,9 @@ class ContextService:
|
||||
async def build_context(
|
||||
self,
|
||||
memory_url: MemoryUrl,
|
||||
depth: int = 2,
|
||||
depth: int = 1,
|
||||
since: Optional[datetime] = None,
|
||||
max_results: int = 10
|
||||
):
|
||||
"""Build rich context from a memory:// URI."""
|
||||
logger.debug(f"Building context for URI {memory_url}")
|
||||
@@ -136,7 +137,6 @@ class ContextService:
|
||||
relation_type,
|
||||
category,
|
||||
entity_id,
|
||||
content,
|
||||
0 as depth,
|
||||
id as root_id,
|
||||
created_at
|
||||
@@ -157,7 +157,6 @@ class ContextService:
|
||||
related.relation_type,
|
||||
related.category,
|
||||
related.entity_id,
|
||||
related.content,
|
||||
cg.depth + 1,
|
||||
cg.root_id,
|
||||
related.created_at
|
||||
@@ -196,14 +195,13 @@ class ContextService:
|
||||
relation_type,
|
||||
category,
|
||||
entity_id,
|
||||
content,
|
||||
MIN(depth) as depth,
|
||||
root_id,
|
||||
created_at
|
||||
FROM context_graph
|
||||
GROUP BY
|
||||
type, id, title, permalink, from_id, to_id,
|
||||
relation_type, category, entity_id, content,
|
||||
relation_type, category, entity_id,
|
||||
root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
""")
|
||||
@@ -222,7 +220,6 @@ class ContextService:
|
||||
relation_type=row.relation_type,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
content=row.content,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
|
||||
@@ -5,9 +5,9 @@ from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import file_utils
|
||||
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
|
||||
from basic_memory.services.exceptions import FileOperationError
|
||||
from basic_memory.utils import file_utils
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,13 @@ from typing import Optional, Tuple, List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.services.service import BaseService
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.models.knowledge import generate_permalink
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
|
||||
|
||||
|
||||
class LinkResolver():
|
||||
class LinkResolver:
|
||||
"""Service for resolving markdown links to permalinks.
|
||||
|
||||
Uses a combination of exact matching and search-based resolution:
|
||||
@@ -31,8 +29,7 @@ class LinkResolver():
|
||||
self,
|
||||
link_text: str,
|
||||
) -> Entity:
|
||||
"""Resolve a markdown link to a permalink.
|
||||
"""
|
||||
"""Resolve a markdown link to a permalink."""
|
||||
logger.debug(f"Resolving link: {link_text}")
|
||||
|
||||
# Clean link text and extract any alias
|
||||
@@ -60,7 +57,6 @@ class LinkResolver():
|
||||
best_match = self._select_best_match(clean_text, results)
|
||||
logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
|
||||
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
||||
|
||||
|
||||
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
||||
"""Normalize link text and extract alias if present.
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from basic_memory.models import Entity as EntityModel, Observation, Relation, ObservationCategory
|
||||
from basic_memory.markdown.schemas import EntityMarkdown
|
||||
from basic_memory.models.knowledge import generate_permalink
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository
|
||||
from basic_memory.services.exceptions import EntityNotFoundError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
|
||||
@@ -6,10 +6,10 @@ from typing import Dict, Sequence
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.file_utils import compute_checksum
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.sync.utils import SyncReport
|
||||
from basic_memory.utils.file_utils import compute_checksum
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -87,7 +87,7 @@ class FileChangeScanner:
|
||||
return result
|
||||
|
||||
async def find_changes(
|
||||
self, directory: Path, db_file_state: Dict[str, FileState]
|
||||
self, directory: Path, db_file_state: Dict[str, FileState]
|
||||
) -> SyncReport:
|
||||
"""Find changes between filesystem and database."""
|
||||
# Get current files and checksums
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Utility functions for basic-memory."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
def sanitize_name(name: str) -> str:
|
||||
"""
|
||||
@@ -27,3 +29,49 @@ def sanitize_name(name: str) -> str:
|
||||
name = re.sub(r"_+", "_", name).strip("_")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def generate_permalink(file_path: str) -> str:
|
||||
"""Generate a stable permalink from a file path.
|
||||
|
||||
Args:
|
||||
file_path: Original file path
|
||||
|
||||
Returns:
|
||||
Normalized permalink that matches validation rules. Converts spaces and underscores
|
||||
to hyphens for consistency.
|
||||
|
||||
Examples:
|
||||
>>> generate_permalink("docs/My Feature.md")
|
||||
'docs/my-feature'
|
||||
>>> generate_permalink("specs/API (v2).md")
|
||||
'specs/api-v2'
|
||||
>>> generate_permalink("design/unified_model_refactor.md")
|
||||
'design/unified-model-refactor'
|
||||
"""
|
||||
# Remove extension
|
||||
base = os.path.splitext(file_path)[0]
|
||||
|
||||
# Transliterate unicode to ascii
|
||||
ascii_text = unidecode(base)
|
||||
|
||||
# Insert dash between camelCase
|
||||
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
|
||||
|
||||
# Convert to lowercase
|
||||
lower_text = ascii_text.lower()
|
||||
|
||||
# replace underscores with hyphens
|
||||
text_with_hyphens = lower_text.replace('_', '-')
|
||||
|
||||
# Replace remaining invalid chars with hyphens
|
||||
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
|
||||
|
||||
# Collapse multiple hyphens
|
||||
clean_text = re.sub(r'-+', '-', clean_text)
|
||||
|
||||
# Clean each path segment
|
||||
segments = clean_text.split('/')
|
||||
clean_segments = [s.strip('-') for s in segments]
|
||||
|
||||
return '/'.join(clean_segments)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Utility functions and helpers."""
|
||||
Reference in New Issue
Block a user