Compare commits

...

7 Commits

Author SHA1 Message Date
semantic-release a6b46908b1 chore(release): 0.5.0 [skip ci] 2025-02-18 05:34:50 +00:00
Paul Hernandez 0689e7a730 feat: return semantic info in markdown after write_note (#11)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 23:33:11 -06:00
semantic-release 16466e9269 chore(release): 0.4.3 [skip ci] 2025-02-18 00:49:28 +00:00
Paul Hernandez 39bd5ca08f fix: Re do enhanced read note format (#10)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 18:47:59 -06:00
semantic-release 4a1f5452c4 chore(release): 0.4.2 [skip ci] 2025-02-17 03:54:57 +00:00
phernandez 9c9960e2ca Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-16 21:49:51 -06:00
phernandez 30cd74ec95 fix: more alembic fixes 2025-02-16 21:49:41 -06:00
22 changed files with 683 additions and 272 deletions
+27
View File
@@ -1,6 +1,30 @@
# CHANGELOG
## v0.5.0 (2025-02-18)
### Features
- Return semantic info in markdown after write_note
([#11](https://github.com/basicmachines-co/basic-memory/pull/11),
[`0689e7a`](https://github.com/basicmachines-co/basic-memory/commit/0689e7a730497827bf4e16156ae402ddc5949077))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.3 (2025-02-18)
### Bug Fixes
- Re do enhanced read note format ([#10](https://github.com/basicmachines-co/basic-memory/pull/10),
[`39bd5ca`](https://github.com/basicmachines-co/basic-memory/commit/39bd5ca08fd057220b95a8b5d82c5e73a1f5722b))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.2 (2025-02-17)
## v0.4.1 (2025-02-17)
### Bug Fixes
@@ -8,6 +32,9 @@
- Fix alemic config
([`71de8ac`](https://github.com/basicmachines-co/basic-memory/commit/71de8acfd0902fc60f27deb3638236a3875787ab))
- More alembic fixes
([`30cd74e`](https://github.com/basicmachines-co/basic-memory/commit/30cd74ec95c04eaa92b41b9815431f5fbdb46ef8))
## v0.4.0 (2025-02-16)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.4.1"
version = "0.5.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.4.1"
__version__ = "0.5.0"
+96 -12
View File
@@ -1,34 +1,118 @@
"""Routes for getting entity content."""
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from loguru import logger
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
from basic_memory.deps import (
ProjectConfigDep,
LinkResolverDep,
SearchServiceDep,
EntityServiceDep,
FileServiceDep,
)
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
router = APIRouter(prefix="/resource", tags=["resources"])
def get_entity_ids(item: SearchIndexRow) -> list[int]:
match item.type:
case SearchItemType.ENTITY:
return [item.id]
case SearchItemType.OBSERVATION:
return [item.entity_id] # pyright: ignore [reportReturnType]
case SearchItemType.RELATION:
from_entity = item.from_id
to_entity = item.to_id # pyright: ignore [reportReturnType]
return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType]
case _: # pragma: no cover
raise ValueError(f"Unexpected type: {item.type}")
@router.get("/{identifier:path}")
async def get_resource_content(
config: ProjectConfigDep,
link_resolver: LinkResolverDep,
search_service: SearchServiceDep,
entity_service: EntityServiceDep,
file_service: FileServiceDep,
background_tasks: BackgroundTasks,
identifier: str,
) -> FileResponse:
"""Get resource content by identifier: name or permalink."""
logger.debug(f"Getting content for permalink: {identifier}")
logger.debug(f"Getting content for: {identifier}")
# Find entity by permalink
# Find single entity by permalink
entity = await link_resolver.resolve_link(identifier)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
results = [entity] if entity else []
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
# search using the identifier as a permalink
if not results:
# if the identifier contains a wildcard, use GLOB search
query = (
SearchQuery(permalink_match=identifier)
if "*" in identifier
else SearchQuery(permalink=identifier)
)
return FileResponse(path=file_path)
search_results = await search_service.search(query)
if not search_results:
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
# get the entities related to the search results
entity_ids = [id for result in search_results for id in get_entity_ids(result)]
results = await entity_service.get_entities_by_id(entity_ids)
# return single response
if len(results) == 1:
entity = results[0]
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
)
return FileResponse(path=file_path)
# for multiple files, initialize a temporary file for writing the results
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
temp_file_path = tmp_file.name
for result in results:
# Read content for each entity
content = await file_service.read_entity_content(result)
memory_url = normalize_memory_url(result.permalink)
modified_date = result.updated_at.isoformat()
assert result.checksum
checksum = result.checksum[:8]
# Prepare the delimited content
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
response_content += f"\n{content}\n"
response_content += "\n"
# Write content directly to the temporary file in append mode
tmp_file.write(response_content)
# Ensure all content is written to disk
tmp_file.flush()
# Schedule the temporary file to be deleted after the response
background_tasks.add_task(cleanup_temp_file, temp_file_path)
# Return the file response
return FileResponse(path=temp_file_path)
def cleanup_temp_file(file_path: str):
"""Delete the temporary file."""
try:
Path(file_path).unlink() # Deletes the file
logger.debug(f"Temporary file deleted: {file_path}")
except Exception as e: # pragma: no cover
logger.error(f"Error deleting temporary file {file_path}: {e}")
@@ -69,7 +69,11 @@ def traverse_messages(
def format_chat_markdown(
title: str, mapping: Dict[str, Any], root_id: Optional[str], created_at: float, modified_at: float
title: str,
mapping: Dict[str, Any],
root_id: Optional[str],
created_at: float,
modified_at: float,
) -> str:
"""Format chat as clean markdown."""
+12 -2
View File
@@ -137,8 +137,18 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
# Get the absolute path to the alembic directory relative to this file
alembic_dir = Path(__file__).parent / "alembic"
config = Config()
# Set required Alembic config options programmatically
config.set_main_option("script_location", str(alembic_dir))
config.set_main_option("sqlalchemy.url", "driver://user:pass@localhost/dbname")
config.set_main_option(
"file_template",
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
)
config.set_main_option("timezone", "UTC")
config.set_main_option("revision_environment", "false")
config.set_main_option(
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
)
command.upgrade(config, "head")
logger.info("Migrations completed successfully")
@@ -147,4 +157,4 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
await SearchRepository(session_maker).init_search_index()
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
raise
+114 -31
View File
@@ -17,42 +17,62 @@ from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
description="Create or update a markdown note. Returns the permalink for referencing.",
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
)
async def write_note(
title: str,
content: str,
folder: str,
tags: Optional[List[str]] = None,
verbose: bool = False,
) -> EntityResponse | str:
) -> str:
"""Write a markdown note to the knowledge base.
The content can include semantic observations and relations using markdown syntax.
Relations can be specified either explicitly or through inline wiki-style links:
Observations format:
`- [category] Observation text #tag1 #tag2 (optional context)`
Examples:
`- [design] Files are the source of truth #architecture (All state comes from files)`
`- [tech] Using SQLite for storage #implementation`
`- [note] Need to add error handling #todo`
Relations format:
- Explicit: `- relation_type [[Entity]] (optional context)`
- Inline: Any `[[Entity]]` reference creates a relation
Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
Args:
title: The title of the note
content: Markdown content for the note
content: Markdown content for the note, can include observations and relations
folder: the folder where the file should be saved
tags: Optional list of tags to categorize the note
verbose: If True, returns full EntityResponse with semantic info
Returns:
If verbose=False: Permalink that can be used to reference the note
If verbose=True: EntityResponse with full semantic details
A markdown formatted summary of the semantic content, including:
- Creation/update status
- File path and checksum
- Observation counts by category
- Relation counts (resolved/unresolved)
- Tags if present
Examples:
# Create a simple note
write_note(
tile="Meeting Notes: Project Planning.md",
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
folder="notes"
)
# Create note with tags
write_note(
title="Security Review",
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
folder="security",
tags=["security", "development"]
title="Search Implementation",
content="# Search Component\\n\\n"
"Implementation of the search feature, building on [[Core Search]].\\n\\n"
"## Observations\\n"
"- [tech] Using FTS5 for full-text search #implementation\\n"
"- [design] Need pagination support #todo\\n\\n"
"## Relations\\n"
"- implements [[Search Spec]]\\n"
"- depends_on [[Database Schema]]",
folder="docs/components"
)
"""
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
@@ -68,34 +88,97 @@ async def write_note(
entity_metadata=metadata,
)
# Use existing knowledge tool
# Create or update via knowledge API
logger.info(f"Creating {entity.permalink}")
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
return result if verbose else result.permalink
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
assert result.checksum is not None
summary = [
f"# {action} {result.file_path} ({result.checksum[:8]})",
f"permalink: {result.permalink}",
]
if result.observations:
categories = {}
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
summary.append("\nUnresolved relations will be retried on next sync.")
if tags:
summary.append(f"\n## Tags\n- {', '.join(tags)}")
return "\n".join(summary)
@mcp.tool(description="Read a note's content by its title or permalink")
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
async def read_note(identifier: str) -> str:
"""Get the markdown content of a note.
Uses the resource router to return the actual file content.
"""Get note content in unified diff format.
The content is returned in a unified diff inspired format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<document content>
```
Multiple documents (from relations or pattern matches) are separated by
additional headers.
Args:
identifier: Note title or permalink
identifier: Can be one of:
- Note title ("Project Planning")
- Note permalink ("docs/example")
- Relation path ("docs/example/depends-on/other-doc")
- Pattern match ("docs/*-architecture")
Returns:
The note's markdown content
Document content in unified diff format. For single documents, returns
just that document's content. For relations or pattern matches, returns
multiple documents separated by unified diff headers.
Examples:
# Read by title
read_note("Meeting Notes: Project Planning")
# Single document
content = await read_note("Project Planning")
# Read by permalink
read_note("notes/project-planning")
content = await read_note("docs/architecture/file-first")
Raises:
ValueError: If the note cannot be found
# Follow relation
content = await read_note("docs/architecture/depends-on/docs/content-parser")
# Pattern matching
content = await read_note("docs/*-architecture") # All architecture docs
content = await read_note("docs/*/implements/*") # Find implementations
Output format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<first document content>
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
<second document content>
```
The headers include:
- Full memory:// URI for the document
- Last modified timestamp
- Content checksum
"""
logger.info(f"Reading note {identifier}")
url = memory_url_path(identifier)
@@ -68,24 +68,40 @@ class SearchRepository:
async def init_search_index(self):
"""Create or recreate the search index."""
logger.info("Initializing search index")
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
try:
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
except Exception as e: # pragma: no cover
logger.error(f"Error initializing search index: {e}")
raise e
def _quote_search_term(self, term: str) -> str:
"""Add quotes if term contains special characters.
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for FTS5 query.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (* suffix)
For FTS5:
- Special characters and phrases need to be quoted
- Terms with spaces or special chars need quotes
"""
# List of special characters that need quoting
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
if "*" in term:
return term
# List of special characters that need quoting (excluding *)
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
# Check if term contains any special characters
if any(c in term for c in special_chars):
# If the term already contains quotes, escape them
needs_quotes = any(c in term for c in special_chars)
if needs_quotes:
# If the term already contains quotes, escape them and add a wildcard
term = term.replace('"', '""')
return f'"{term}"'
term = f'"{term}"*'
return term
async def search(
@@ -106,14 +122,14 @@ class SearchRepository:
# Handle text search for title and content
if search_text:
search_text = self._quote_search_term(search_text.lower().strip())
params["text"] = f"{search_text}*"
search_text = self._prepare_search_term(search_text.strip())
params["text"] = search_text
conditions.append("(title MATCH :text OR content MATCH :text)")
# Handle title match search
if title:
title_text = self._quote_search_term(title.lower().strip())
params["text"] = f"{title_text}*"
title_text = self._prepare_search_term(title.strip())
params["text"] = title_text
conditions.append("title MATCH :text")
# Handle permalink exact search
@@ -123,8 +139,15 @@ class SearchRepository:
# Handle permalink match search, supports *
if permalink_match:
params["permalink"] = self._quote_search_term(permalink_match)
conditions.append("permalink MATCH :permalink")
# Clean and prepare permalink for FTS5 GLOB match
permalink_text = self._prepare_search_term(
permalink_match.lower().strip(), is_prefix=False
)
params["permalink"] = permalink_text
if "*" in permalink_match:
conditions.append("permalink GLOB :permalink")
else:
conditions.append("permalink MATCH :permalink")
# Handle type filter
if types:
@@ -173,7 +196,7 @@ class SearchRepository:
LIMIT :limit
"""
# logger.debug(f"Search {sql} params: {params}")
logger.debug(f"Search {sql} params: {params}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
@@ -199,8 +222,11 @@ class SearchRepository:
for row in rows
]
# for r in results:
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
logger.debug(f"Found {len(results)} search results")
for r in results:
logger.debug(
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
@@ -233,7 +259,7 @@ class SearchRepository:
"""),
search_index_row.to_insert(),
)
logger.debug(f"indexed permalink {search_index_row.permalink}")
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def delete_by_permalink(self, permalink: str):
+8
View File
@@ -11,6 +11,7 @@ Key Features:
4. Bulk operations return all affected items
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
@@ -43,6 +44,8 @@ class ObservationResponse(Observation, SQLAlchemyModel):
}
"""
permalink: Permalink
class RelationResponse(Relation, SQLAlchemyModel):
"""Response schema for relation operations.
@@ -59,6 +62,8 @@ class RelationResponse(Relation, SQLAlchemyModel):
}
"""
permalink: Permalink
from_id: Permalink = Field(
# use the permalink from the associated Entity
# or the from_id value
@@ -131,9 +136,12 @@ class EntityResponse(SQLAlchemyModel):
file_path: str
entity_type: EntityType
entity_metadata: Optional[Dict] = None
checksum: Optional[str] = None
content_type: ContentType
observations: List[ObservationResponse] = []
relations: List[RelationResponse] = []
created_at: datetime
updated_at: datetime
class EntityListResponse(SQLAlchemyModel):
@@ -185,6 +185,11 @@ class EntityService(BaseService[EntityModel]):
raise EntityNotFoundError(f"Entity not found: {permalink}")
return db_entity
async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
"""Get specific entities and their relationships."""
logger.debug(f"Getting entities: {ids}")
return await self.repository.find_by_ids(ids)
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
"""Get specific nodes and their relationships."""
logger.debug(f"Getting entities permalinks: {permalinks}")
+8 -7
View File
@@ -16,9 +16,10 @@ class LinkResolver:
Uses a combination of exact matching and search-based resolution:
1. Try exact permalink match (fastest)
2. Try exact title match
3. Fall back to search for fuzzy matching
4. Generate new permalink if no match found
2. Try permalink pattern match (for wildcards)
3. Try exact title match
4. Fall back to search for fuzzy matching
5. Generate new permalink if no match found
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -45,8 +46,8 @@ class LinkResolver:
logger.debug(f"Found title match: {entity.title}")
return entity
if use_search:
# 3. Fall back to search for fuzzy matching on title if specified
if use_search and "*" not in clean_text:
# 3. Fall back to search for fuzzy matching on title
results = await self.search_service.search(
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
)
@@ -59,7 +60,7 @@ class LinkResolver:
)
return await self.entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
# if we couldn't find anything then return None
return None
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
@@ -87,7 +88,7 @@ class LinkResolver:
return text, alias
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
"""Select best match from search results.
Uses multiple criteria:
+1 -1
View File
@@ -75,7 +75,7 @@ class SearchService:
else None
)
# permalink search
# search
results = await self.repository.search(
search_text=query.text,
permalink=query.permalink,
+138 -1
View File
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
import pytest
from pathlib import Path
from basic_memory.schemas import EntityResponse
@pytest.mark.asyncio
async def test_get_resource_content(client, test_config, entity_repository):
@@ -67,7 +69,7 @@ async def test_get_resource_missing_entity(client):
"""Test 404 when entity doesn't exist."""
response = await client.get("/resource/does/not/exist")
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
assert "Resource not found" in response.json()["detail"]
@pytest.mark.asyncio
@@ -89,3 +91,138 @@ async def test_get_resource_missing_file(client, test_config, entity_repository)
response = await client.get(f"/resource/{entity.permalink}")
assert response.status_code == 404
assert "File not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_resource_observation(client, test_config, entity_repository):
"""Test getting content by observation permalink."""
# Create entity
content = "# Test Content\n\n- [note] an observation."
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity = EntityResponse(**entity_response)
assert len(entity.observations) == 1
observation = entity.observations[0]
# Test getting the content via the observation
response = await client.get(f"/resource/{observation.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
"""
---
title: Test Entity
type: test
permalink: test/test-entity
---
# Test Content
- [note] an observation.
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_get_resource_entities(client, test_config, entity_repository):
"""Test getting content by permalink match."""
# Create entity
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content1}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity1 = EntityResponse(**entity_response)
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content2}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
# Test getting the content via the relation
response = await client.get("/resource/test/*")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
# Test Content
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
# Related Content
- links to [[Test Entity]]
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_get_resource_relation(client, test_config, entity_repository):
"""Test getting content by relation permalink."""
# Create entity
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content1}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity1 = EntityResponse(**entity_response)
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content2}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
relation = entity2.relations[0]
# Test getting the content via the relation
response = await client.get(f"/resource/{relation.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
# Test Content
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
# Related Content
- links to [[Test Entity]]
""".strip()
in response.text
)
-21
View File
@@ -308,27 +308,6 @@ async def test_graph(
for entity in entities:
await search_service.index_entity(entity)
# search_content = await entity_repository.execute_query(text("select * from search_index"),
# use_query_options=False)
# for row in search_content:
# print(row)
# print("relation:")
# search_content = await entity_repository.execute_query(text("select * from search_index where type = 'relation'"),
# use_query_options=False)
# for row in search_content:
# print(row)
#
# # In test_graph fixture after creating everything:
# print("Entities:")
# entities = await entity_repository.find_all()
# for e in entities:
# print(f"- {e.title} (id={e.id})")
#
# print("\nRelations:")
# relations = await relation_repository.find_all()
# for r in relations:
# print(f"- {r.from_id} -> {r.to_id} ({r.relation_type})")
return {
"root": root,
"connected1": connected_1,
-45
View File
@@ -1,45 +0,0 @@
"""Tests for get_entity MCP tool."""
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import notes
from basic_memory.mcp.tools.knowledge import get_entity
@pytest.mark.asyncio
async def test_get_basic_entity(client):
"""Test retrieving a basic entity."""
# First create an entity
permalink = await notes.write_note(
title="Test Note",
folder="test",
content="""
# Test\nThis is a test note
- [note] First observation
""",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
# Get the entity without content
entity = await get_entity(permalink)
# Verify entity details
assert entity.file_path == "test/Test Note.md"
assert entity.entity_type == "note"
assert entity.permalink == "test/test-note"
# Check observations
assert len(entity.observations) == 1
obs = entity.observations[0]
assert obs.content == "First observation"
assert obs.category == "note"
@pytest.mark.asyncio
async def test_get_nonexistent_entity(client):
"""Test attempting to get a non-existent entity."""
with pytest.raises(ToolError):
await get_entity("test/nonexistent")
+11 -10
View File
@@ -13,7 +13,7 @@ from basic_memory.schemas.delete import DeleteEntitiesRequest
async def test_get_single_entity(client):
"""Test retrieving a single entity."""
# First create an entity
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="""
@@ -22,9 +22,10 @@ async def test_get_single_entity(client):
""",
tags=["test", "documentation"],
)
assert result
# Get the entity
entity = await get_entity(permalink)
entity = await get_entity("test/test-note")
# Verify entity details
assert entity.title == "Test Note"
@@ -36,40 +37,40 @@ async def test_get_single_entity(client):
async def test_get_multiple_entities(client):
"""Test retrieving multiple entities."""
# Create two test entities
permalink1 = await notes.write_note(
await notes.write_note(
title="Test Note 1",
folder="test",
content="# Test 1",
)
permalink2 = await notes.write_note(
await notes.write_note(
title="Test Note 2",
folder="test",
content="# Test 2",
)
# Get both entities
request = GetEntitiesRequest(permalinks=[permalink1, permalink2])
request = GetEntitiesRequest(permalinks=["test/test-note-1", "test/test-note-2"])
response = await get_entities(request)
# Verify we got both entities
assert len(response.entities) == 2
permalinks = {e.permalink for e in response.entities}
assert permalink1 in permalinks
assert permalink2 in permalinks
assert "test/test-note-1" in permalinks
assert "test/test-note-2" in permalinks
@pytest.mark.asyncio
async def test_delete_entities(client):
"""Test deleting entities."""
# Create a test entity
permalink = await notes.write_note(
await notes.write_note(
title="Test Note",
folder="test",
content="# Test Note to Delete",
)
# Delete the entity
request = DeleteEntitiesRequest(permalinks=[permalink])
request = DeleteEntitiesRequest(permalinks=["test/test-note"])
response = await delete_entities(request)
# Verify deletion
@@ -77,7 +78,7 @@ async def test_delete_entities(client):
# Verify entity no longer exists
with pytest.raises(ToolError):
await get_entity(permalink)
await get_entity("test/test-note")
@pytest.mark.asyncio
+130 -67
View File
@@ -1,10 +1,11 @@
"""Tests for note tools that exercise the full stack with SQLite."""
from textwrap import dedent
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import notes
from basic_memory.schemas import EntityResponse
@pytest.mark.asyncio
@@ -17,31 +18,41 @@ async def test_write_note(app):
- Handle tags correctly
- Return valid permalink
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result
assert (
dedent("""
# Created test/Test Note.md (159f2168)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
# Try reading it back via permalink
content = await notes.read_note(permalink)
content = await notes.read_note("test/test-note")
assert (
"""
---
title: Test Note
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
---
# Test
This is a test note
""".strip()
dedent("""
---
title: Test Note
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
---
# Test
This is a test note
""").strip()
in content
)
@@ -49,20 +60,28 @@ This is a test note
@pytest.mark.asyncio
async def test_write_note_no_tags(app):
"""Test creating a note without tags."""
permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
result = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
# Should be able to read it back
content = await notes.read_note(permalink)
assert result
assert (
"""
--
title: Simple Note
type: note
permalink: test/simple-note
---
Just some text
""".strip()
dedent("""
# Created test/Simple Note.md (9a1ff079)
permalink: test/simple-note
""").strip()
in result
)
# Should be able to read it back
content = await notes.read_note("test/simple-note")
assert (
dedent("""
--
title: Simple Note
type: note
permalink: test/simple-note
---
Just some text
""").strip()
in content
)
@@ -84,24 +103,44 @@ async def test_write_note_update_existing(app):
- Handle tags correctly
- Return valid permalink
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result # Got a valid permalink
assert (
dedent("""
# Created test/Test Note.md (159f2168)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is an updated note",
tags=["test", "documentation"],
)
assert (
dedent("""
# Updated test/Test Note.md (131b5662)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
# Try reading it back
content = await notes.read_note(permalink)
content = await notes.read_note("test/test-note")
assert (
"""
---
@@ -135,10 +174,18 @@ async def test_read_note_by_title(app):
async def test_note_unicode_content(app):
"""Test handling of unicode content in notes."""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
permalink = await notes.write_note(title="Unicode Test", folder="test", content=content)
result = await notes.write_note(title="Unicode Test", folder="test", content=content)
assert (
dedent("""
# Created test/Unicode Test.md (272389cd)
permalink: test/unicode-test
""").strip()
in result
)
# Read back should preserve unicode
result = await notes.read_note(permalink)
result = await notes.read_note("test/unicode-test")
assert content in result
@@ -147,20 +194,32 @@ async def test_multiple_notes(app):
"""Test creating and managing multiple notes."""
# Create several notes
notes_data = [
("Note 1", "test", "Content 1", ["tag1"]),
("Note 2", "test", "Content 2", ["tag1", "tag2"]),
("Note 3", "test", "Content 3", []),
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
("test/note-3", "Note 3", "test", "Content 3", []),
]
permalinks = []
for title, folder, content, tags in notes_data:
permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags)
permalinks.append(permalink)
for _, title, folder, content, tags in notes_data:
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for i, permalink in enumerate(permalinks):
content = await notes.read_note(permalink)
assert f"Content {i + 1}" in content
for permalink, title, folder, content, _ in notes_data:
note = await notes.read_note(permalink)
assert content in note
# read multiple notes at once
result = await notes.read_note("test/*")
# note we can't compare times
assert "--- memory://test/note-1" in result
assert "Content 1" in result
assert "--- memory://test/note-2" in result
assert "Content 2" in result
assert "--- memory://test/note-3" in result
assert "Content 3" in result
@pytest.mark.asyncio
@@ -172,16 +231,16 @@ async def test_delete_note_existing(app):
- Return valid permalink
- Delete the note
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result
deleted = await notes.delete_note(permalink)
deleted = await notes.delete_note("test/test-note")
assert deleted is True
@@ -207,7 +266,7 @@ async def test_write_note_verbose(app):
- Handle tags correctly
- Return valid permalink
"""
entity = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="""
@@ -218,24 +277,27 @@ async def test_write_note_verbose(app):
""",
tags=["test", "documentation"],
verbose=True,
)
assert isinstance(entity, EntityResponse)
assert entity.title == "Test Note"
assert entity.file_path == "test/Test Note.md"
assert entity.entity_type == "note"
assert entity.permalink == "test/test-note"
assert len(entity.observations) == 1
assert entity.observations[0].content == "First observation"
assert len(entity.relations) == 1
assert entity.relations[0].relation_type == "relates to"
assert entity.relations[0].from_id == "test/test-note"
assert entity.relations[0].to_id is None
assert entity.relations[0].to_name == "Knowledge"
assert (
dedent("""
# Created test/Test Note.md (06873a7a)
permalink: test/test-note
## Observations
- note: 1
## Relations
- Resolved: 0
- Unresolved: 1
Unresolved relations will be retried on next sync.
## Tags
- test, documentation
""").strip()
in result
)
@pytest.mark.asyncio
@@ -248,13 +310,14 @@ async def test_read_note_memory_url(app):
- Return the note content
"""
# First create a note
permalink = await notes.write_note(
result = await notes.write_note(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling",
)
assert result
# Should be able to read it with a memory:// URL
memory_url = f"memory://{permalink}"
memory_url = "memory://test/memory-url-test"
content = await notes.read_note(memory_url)
assert "Testing memory:// URL handling" in content
+3 -2
View File
@@ -12,12 +12,13 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
async def test_search_basic(client):
"""Test basic search functionality."""
# Create a test note
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it
query = SearchQuery(text="searchable")
@@ -25,7 +26,7 @@ async def test_search_basic(client):
# Verify results
assert len(response.results) > 0
assert any(r.permalink == permalink for r in response.results)
assert any(r.permalink == "test/test-search-note" for r in response.results)
@pytest.mark.asyncio
+19 -7
View File
@@ -51,16 +51,17 @@ def test_relation_in_validation():
def test_relation_response():
"""Test RelationResponse validation."""
data = {
"permalink": "test/123/relates_to/test/456",
"from_id": "test/123",
"to_id": "test/456",
"relation_type": "test",
"relation_type": "relates_to",
"from_entity": {"permalink": "test/123"},
"to_entity": {"permalink": "test/456"},
}
relation = RelationResponse.model_validate(data)
assert relation.from_id == "test/123"
assert relation.to_id == "test/456"
assert relation.relation_type == "test"
assert relation.relation_type == "relates_to"
assert relation.context is None
@@ -73,16 +74,27 @@ def test_entity_out_from_attributes():
"file_path": "test",
"entity_type": "knowledge",
"content_type": "text/markdown",
"observations": [{"id": 1, "category": "note", "content": "test obs", "context": None}],
"relations": [
"observations": [
{
"id": 1,
"from_id": "test/test",
"to_id": "test/test",
"relation_type": "test",
"permalink": "permalink",
"category": "note",
"content": "test obs",
"context": None,
}
],
"relations": [
{
"id": 1,
"permalink": "test/test/relates_to/test/test",
"from_id": "test/test",
"to_id": "test/test",
"relation_type": "relates_to",
"context": None,
}
],
"created_at": "2023-01-01T00:00:00",
"updated_at": "2023-01-01T00:00:00",
}
entity = EntityResponse.model_validate(db_data)
assert entity.permalink == "test/test"
+26 -29
View File
@@ -10,7 +10,17 @@ from basic_memory.services.link_resolver import LinkResolver
@pytest_asyncio.fixture
async def test_entities(entity_service, file_service):
"""Create a set of test entities."""
"""Create a set of test entities.
├── components
│ ├── Auth Service.md
│ └── Core Service.md
├── config
│ └── Service Config.md
└── specs
└── Core Features.md
"""
e1, _ = await entity_service.create_or_update_entity(
EntitySchema(
@@ -40,6 +50,20 @@ async def test_entities(entity_service, file_service):
folder="specs",
)
)
e5, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Sub Features 1",
entity_type="specs",
folder="specs/subspec",
)
)
e6, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Sub Features 2",
entity_type="specs",
folder="specs/subspec",
)
)
return [e1, e2, e3, e4]
@@ -80,6 +104,7 @@ async def test_fuzzy_title_match_misspelling(link_resolver):
async def test_fuzzy_title_partial_match(link_resolver):
# Test partial match
result = await link_resolver.resolve_link("Auth Serv")
assert result is not None, "Did not find partial match"
assert result.permalink == "components/auth-service"
@@ -114,31 +139,3 @@ async def test_resolve_none(link_resolver):
"""Test resolving non-existent entity."""
# Basic new entity
assert await link_resolver.resolve_link("New Feature") is None
@pytest.mark.skip("Advanced relevance scoring not yet implemented")
@pytest.mark.asyncio
async def test_multiple_matches_resolution(link_resolver):
"""Test resolution when multiple potential matches exist."""
# Add some similar entities
test_cases = [
{
"link": "Service", # Ambiguous
"expected_prefix": "components/", # Should prefer component directory match
},
{
"link": "Core", # Ambiguous
"expected_prefix": "specs/", # Should prefer specs directory match
},
{
"link": "Service",
"expected": "components/core-service", # Should pick shortest/highest scored
},
]
for case in test_cases:
result = await link_resolver.resolve_link(case["link"])
if "expected_prefix" in case:
assert result.startswith(case["expected_prefix"])
else:
assert result == case["expected"]
+29 -11
View File
@@ -32,21 +32,24 @@ async def test_search_permalink_observations_wildcard(search_service, test_graph
@pytest.mark.asyncio
async def test_search_permalink_relation_wildcard(search_service, test_graph):
"""Pattern matching"""
results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*"))
results = await search_service.search(SearchQuery(permalink_match="test/root/connects-to/*"))
assert len(results) == 1
permalinks = {r.permalink for r in results}
assert "test/root/connects-to/test/connected-entity-1" in permalinks
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
@pytest.mark.asyncio
async def test_search_permalink_wildcard2(search_service, test_graph):
"""Pattern matching"""
results = await search_service.search(SearchQuery(permalink_match="test/connected*"))
assert len(results) == 2
results = await search_service.search(
SearchQuery(
permalink_match="test/connected*",
)
)
assert len(results) >= 2
permalinks = {r.permalink for r in results}
assert "test/connected1" in permalinks
assert "test/connected2" in permalinks
assert "test/connected-entity-1" in permalinks
assert "test/connected-entity-2" in permalinks
@pytest.mark.asyncio
@@ -68,17 +71,27 @@ async def test_search_title(search_service, test_graph):
@pytest.mark.asyncio
async def test_text_search_features(search_service, test_graph):
async def test_text_search_case_insensitive(search_service, test_graph):
"""Test text search functionality."""
# Case insensitive
results = await search_service.search(SearchQuery(text="ENTITY"))
assert any("test/root" in r.permalink for r in results)
# Partial word match
results = await search_service.search(SearchQuery(text="Connect"))
@pytest.mark.asyncio
async def test_text_search_content_word_match(search_service, test_graph):
"""Test text search functionality."""
# content word match
results = await search_service.search(SearchQuery(text="Connected"))
assert len(results) > 0
assert any(r.file_path == "test/Connected Entity 2.md" for r in results)
@pytest.mark.asyncio
async def test_text_search_multiple_terms(search_service, test_graph):
"""Test text search functionality."""
# Multiple terms
results = await search_service.search(SearchQuery(text="root note"))
assert any("test/root" in r.permalink for r in results)
@@ -88,15 +101,20 @@ async def test_text_search_features(search_service, test_graph):
async def test_pattern_matching(search_service, test_graph):
"""Test pattern matching with various wildcards."""
# Test wildcards
results = await search_service.search(SearchQuery(permalink="test/*"))
results = await search_service.search(SearchQuery(permalink_match="test/*"))
for r in results:
assert "test/" in r.permalink
# Test start wildcards
results = await search_service.search(SearchQuery(permalink="*/observations"))
results = await search_service.search(SearchQuery(permalink_match="*/observations"))
for r in results:
assert "/observations" in r.permalink
# Test permalink partial match
results = await search_service.search(SearchQuery(permalink_match="test"))
for r in results:
assert "test/" in r.permalink
@pytest.mark.asyncio
async def test_filters(search_service, test_graph):
Generated
+1 -1
View File
@@ -70,7 +70,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.3.0"
version = "0.4.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },