diff --git a/src/basic_memory/schemas/memory_url.py b/src/basic_memory/schemas/memory_url.py new file mode 100644 index 00000000..9f45ae07 --- /dev/null +++ b/src/basic_memory/schemas/memory_url.py @@ -0,0 +1,99 @@ +"""Memory URL schema for knowledge addressing. + +The memory:// URL scheme provides a unified way to address knowledge across projects: +memory://project-name/path/to/content + +The host portion (project-name) identifies the knowledge base context, while +the path represents a relative permalink within that project. + +Examples: + memory://basic-memory/specs/search/* # Pattern matching + memory://basic-memory/topic/search~ranking # Fuzzy search + memory://basic-memory/specs/link-resolution # Exact permalink + memory://basic-memory/related/sync # Related content + memory://basic-memory/context/current # Context building +""" + +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field, field_validator, ValidationError + + +class MemoryUrl(BaseModel): + """memory:// URL scheme for knowledge addressing.""" + + scheme: str = Field(default="memory", frozen=True) + host: str + path: str + + # Special fields for pattern matching and context + pattern: Optional[str] = None + fuzzy: Optional[str] = None + params: Dict[str, Any] = Field(default_factory=dict) + + @field_validator("scheme") + @classmethod + def validate_scheme(cls, v: str) -> str: + """Validate URL scheme.""" + if v != "memory": + raise ValueError("URL must use memory:// scheme") + return v + + @field_validator("host") + @classmethod + def validate_host(cls, v: Optional[str]) -> str: + """Validate host (project identifier).""" + if not v: + raise ValueError("URL must include project/context identifier") + return v + + @classmethod + def parse(cls, url_str: str) -> "MemoryUrl": + """Parse a memory:// URL string.""" + # Split scheme and rest + if "://" not in url_str: + raise ValueError("URL must include scheme (memory://)") + + scheme, rest = url_str.split("://", 1) + + # Split host and path + parts = rest.split("/", 1) + if len(parts) != 2: + raise ValueError("URL must include both host and path") + + host, path = parts + path = "/" + path # Add leading slash for consistency + + # Create base URL + url = cls(scheme=scheme, host=host, path=path) + + # Parse special patterns + path_no_slash = path[1:] if path.startswith('/') else path + + # Handle glob patterns + if '*' in path_no_slash: + url.pattern = path_no_slash.replace('*', '%') + + # Handle fuzzy search + if '~' in path_no_slash: + url.fuzzy = path_no_slash.replace('~', ' ') + + # Extract special prefixes + segments = path_no_slash.split('/') + if segments and segments[0] in {'related', 'context'}: + url.params['type'] = segments[0] + url.params['target'] = '/'.join(segments[1:]) + + return url + + @property + def project(self) -> str: + """Get the project/context identifier.""" + return self.host + + def relative_path(self) -> str: + """Get the path without leading slash.""" + return self.path[1:] if self.path.startswith('/') else self.path + + def __str__(self) -> str: + """Convert back to URL string.""" + return f"memory://{self.host}{self.path}" \ No newline at end of file diff --git a/tests/schemas/test_memory_url.py b/tests/schemas/test_memory_url.py new file mode 100644 index 00000000..1aba4c5d --- /dev/null +++ b/tests/schemas/test_memory_url.py @@ -0,0 +1,103 @@ +"""Tests for MemoryUrl parsing.""" + +import pytest +from pydantic import ValidationError +from basic_memory.schemas.memory_url import MemoryUrl + + +def test_basic_permalink(): + """Test basic permalink parsing.""" + url = MemoryUrl.parse("memory://basic-memory/specs/search") + assert url.scheme == "memory" + assert url.host == "basic-memory" + assert url.path == "/specs/search" + assert url.pattern is None + assert url.fuzzy is None + assert url.params == {} + + +def test_glob_pattern(): + """Test glob pattern conversion.""" + url = MemoryUrl.parse("memory://basic-memory/specs/search/*") + assert url.host == "basic-memory" + assert url.path == "/specs/search/*" + assert url.pattern == "specs/search/%" + assert url.fuzzy is None + + +def test_fuzzy_search(): + """Test fuzzy search term parsing.""" + url = MemoryUrl.parse("memory://basic-memory/topic/search~ranking") + assert url.host == "basic-memory" + assert url.path == "/topic/search~ranking" + assert url.pattern is None + assert url.fuzzy == "topic/search ranking" + + +def test_related_prefix(): + """Test related content prefix.""" + url = MemoryUrl.parse("memory://basic-memory/related/specs/search") + assert url.host == "basic-memory" + assert url.path == "/related/specs/search" + assert url.params == { + "type": "related", + "target": "specs/search" + } + + +def test_context_prefix(): + """Test context prefix.""" + url = MemoryUrl.parse("memory://basic-memory/context/current") + assert url.host == "basic-memory" + assert url.path == "/context/current" + assert url.params == { + "type": "context", + "target": "current" + } + + +def test_invalid_scheme(): + """Test that other schemes are rejected.""" + with pytest.raises(ValidationError): + MemoryUrl.parse("http://basic-memory/specs/search") + + +def test_missing_host(): + """Test that host is required.""" + with pytest.raises(ValidationError): + MemoryUrl.parse("memory:///specs/search") + + +def test_complex_pattern(): + """Test multiple glob patterns.""" + url = MemoryUrl.parse("memory://basic-memory/specs/*/search/*") + assert url.host == "basic-memory" + assert url.path == "/specs/*/search/*" + assert url.pattern == "specs/%/search/%" + + +def test_complex_fuzzy(): + """Test multiple fuzzy terms.""" + url = MemoryUrl.parse("memory://basic-memory/specs/search~ranking~performance") + assert url.host == "basic-memory" + assert url.path == "/specs/search~ranking~performance" + assert url.fuzzy == "specs/search ranking performance" + + +def test_url_reconstruction(): + """Test converting back to string.""" + original = "memory://basic-memory/specs/search" + url = MemoryUrl.parse(original) + assert str(url) == original + + +def test_relative_path(): + """Test getting path without leading slash.""" + url = MemoryUrl.parse("memory://basic-memory/specs/search") + assert url.relative_path() == "specs/search" + + +def test_project_property(): + """Test project name access.""" + url = MemoryUrl.parse("memory://basic-memory/specs/search") + assert url.project == "basic-memory" \ No newline at end of file