Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/basic_memory/cli/commands/cloud/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ async def make_api_request(
# Handle both FastAPI HTTPException format (nested under "detail")
# and direct format
detail_obj = error_detail.get("detail", error_detail)
if isinstance(detail_obj, dict) and detail_obj.get("error") == "subscription_required":
if (
isinstance(detail_obj, dict)
and detail_obj.get("error") == "subscription_required"
):
message = detail_obj.get("message", "Active subscription required")
subscribe_url = detail_obj.get(
"subscribe_url", "https://basicmemory.com/subscribe"
Expand Down
15 changes: 11 additions & 4 deletions src/basic_memory/mcp/prompts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,17 @@ def format_prompt_context(context: PromptContext) -> str:

added_permalinks.add(primary_permalink)

memory_url = normalize_memory_url(primary_permalink)
# Use permalink if available, otherwise use file_path
if primary_permalink:
memory_url = normalize_memory_url(primary_permalink)
read_command = f'read_note("{primary_permalink}")'
else:
memory_url = f"file://{primary.file_path}"
read_command = f'read_file("{primary.file_path}")'

section = dedent(f"""
--- {memory_url}

## {primary.title}
- **Type**: {primary.type}
""")
Expand All @@ -121,8 +128,8 @@ def format_prompt_context(context: PromptContext) -> str:
section += f"\n**Excerpt**:\n{content}\n"

section += dedent(f"""
You can read this document with: `read_note("{primary_permalink}")`

You can read this document with: `{read_command}`
""")
sections.append(section)

Expand Down
37 changes: 26 additions & 11 deletions src/basic_memory/schemas/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def validate_memory_url_path(path: str) -> bool:
>>> validate_memory_url_path("invalid://test") # Contains protocol
False
"""
# Empty paths are not valid
if not path or not path.strip():
return False

Expand Down Expand Up @@ -68,7 +69,13 @@ def normalize_memory_url(url: str | None) -> str:
ValueError: Invalid memory URL path: 'memory//test' contains double slashes
"""
if not url:
return ""
raise ValueError("Memory URL cannot be empty")

# Strip whitespace for consistency
url = url.strip()

if not url:
raise ValueError("Memory URL cannot be empty or whitespace")

clean_path = url.removeprefix("memory://")

Expand All @@ -79,8 +86,6 @@ def normalize_memory_url(url: str | None) -> str:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
elif "//" in clean_path:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
elif not clean_path.strip():
raise ValueError("Memory URL path cannot be empty or whitespace")
else:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")

Expand Down Expand Up @@ -123,7 +128,9 @@ class EntitySummary(BaseModel):
title: str
content: Optional[str] = None
file_path: str
created_at: datetime
created_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
]

@field_serializer("created_at")
def serialize_created_at(self, dt: datetime) -> str:
Expand All @@ -140,7 +147,9 @@ class RelationSummary(BaseModel):
relation_type: str
from_entity: Optional[str] = None
to_entity: Optional[str] = None
created_at: datetime
created_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
]

@field_serializer("created_at")
def serialize_created_at(self, dt: datetime) -> str:
Expand All @@ -156,7 +165,9 @@ class ObservationSummary(BaseModel):
permalink: str
category: str
content: str
created_at: datetime
created_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
]

@field_serializer("created_at")
def serialize_created_at(self, dt: datetime) -> str:
Expand All @@ -170,7 +181,9 @@ class MemoryMetadata(BaseModel):
types: Optional[List[SearchItemType]] = None
depth: int
timeframe: Optional[str] = None
generated_at: datetime
generated_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
]
primary_count: Optional[int] = None # Changed field name
related_count: Optional[int] = None # Changed field name
total_results: Optional[int] = None # For backward compatibility
Expand Down Expand Up @@ -235,9 +248,9 @@ class ProjectActivity(BaseModel):
project_path: str
activity: GraphContext = Field(description="The actual activity data for this project")
item_count: int = Field(description="Total items in this project's activity")
last_activity: Optional[datetime] = Field(
default=None, description="Most recent activity timestamp"
)
last_activity: Optional[
Annotated[datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})]
] = Field(default=None, description="Most recent activity timestamp")
active_folders: List[str] = Field(default_factory=list, description="Most active folders")

@field_serializer("last_activity")
Expand All @@ -253,7 +266,9 @@ class ProjectActivitySummary(BaseModel):
)
summary: ActivityStats
timeframe: str = Field(description="The timeframe used for the query")
generated_at: datetime
generated_at: Annotated[
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
]
guidance: Optional[str] = Field(
default=None, description="Assistant guidance for project selection and session management"
)
Expand Down
4 changes: 3 additions & 1 deletion src/basic_memory/sync/watch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: a
ignore_patterns = self._get_ignore_patterns(project_path)

if should_ignore_path(file_path, project_path, ignore_patterns):
logger.trace(f"Ignoring watched file change: {file_path.relative_to(project_path)}")
logger.trace(
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
)
continue

project_changes[project].append((change, path))
Expand Down
7 changes: 3 additions & 4 deletions test-int/mcp/test_build_context_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def test_build_context_empty_urls_fail_validation(mcp_server, app, test_pr
"""Test that empty or whitespace-only URLs fail validation."""

async with Client(mcp_server) as client:
# These should fail MinLen validation
# These should fail validation
empty_urls = [
"", # Empty string
" ", # Whitespace only
Expand All @@ -83,10 +83,9 @@ async def test_build_context_empty_urls_fail_validation(mcp_server, app, test_pr
)

error_message = str(exc_info.value)
# Should fail with validation error (either MinLen or our custom validation)
# Should fail with validation error
assert (
"at least 1" in error_message
or "too_short" in error_message
"cannot be empty" in error_message
or "empty or whitespace" in error_message
or "value_error" in error_message
or "should be non-empty" in error_message
Expand Down
4 changes: 1 addition & 3 deletions tests/cli/test_cloud_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,7 @@ async def test_parse_subscription_required_error_flat_format(self):
mock_response.headers = {}

# Create HTTPStatusError with the mock response
http_error = httpx.HTTPStatusError(
"403 Forbidden", request=Mock(), response=mock_response
)
http_error = httpx.HTTPStatusError("403 Forbidden", request=Mock(), response=mock_response)

# Mock httpx client to raise the error
with patch("basic_memory.cli.commands.cloud.api_client.httpx.AsyncClient") as mock_client:
Expand Down
7 changes: 5 additions & 2 deletions tests/schemas/test_memory_url.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for MemoryUrl parsing."""

import pytest

from basic_memory.schemas.memory import memory_url, memory_url_path, normalize_memory_url


Expand Down Expand Up @@ -59,5 +61,6 @@ def test_normalize_memory_url_no_prefix():


def test_normalize_memory_url_empty():
"""Test converting back to string."""
assert normalize_memory_url("") == ""
"""Test that empty string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
normalize_memory_url("")
18 changes: 10 additions & 8 deletions tests/schemas/test_memory_url_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,11 @@ def test_valid_normalization(self):
)

def test_empty_url(self):
"""Test that empty URLs return empty string."""
assert normalize_memory_url(None) == ""
assert normalize_memory_url("") == ""
"""Test that empty URLs raise ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
normalize_memory_url(None)
with pytest.raises(ValueError, match="cannot be empty"):
normalize_memory_url("")

def test_invalid_double_slashes(self):
"""Test that URLs with double slashes raise ValueError."""
Expand Down Expand Up @@ -145,14 +147,14 @@ def test_invalid_protocol_schemes(self):

def test_whitespace_only(self):
"""Test that whitespace-only URLs raise ValueError."""
invalid_urls = [
whitespace_urls = [
" ",
"\t",
"\n",
" \n ",
]

for url in invalid_urls:
for url in whitespace_urls:
with pytest.raises(ValueError, match="cannot be empty or whitespace"):
normalize_memory_url(url)

Expand Down Expand Up @@ -206,9 +208,9 @@ def test_invalid_urls_fail_validation(self):
error_msg = str(exc_info.value)
assert "value_error" in error_msg, f"Should be a value_error for '{url}'"

def test_empty_string_fails_minlength(self):
"""Test that empty strings fail MinLen validation."""
with pytest.raises(ValidationError, match="at least 1"):
def test_empty_string_fails_validation(self):
"""Test that empty strings fail validation."""
with pytest.raises(ValidationError, match="cannot be empty"):
memory_url.validate_python("")

def test_very_long_urls_fail_maxlength(self):
Expand Down
Loading