Compare commits

..

2 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
11 changed files with 200 additions and 152 deletions
+11
View File
@@ -1,6 +1,17 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.4.3"
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.3"
__version__ = "0.5.0"
@@ -31,7 +31,7 @@ def get_entity_ids(item: SearchIndexRow) -> list[int]:
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 _:
case _: # pragma: no cover
raise ValueError(f"Unexpected type: {item.type}")
+41 -23
View File
@@ -17,15 +17,14 @@ 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.
@@ -53,14 +52,16 @@ async def write_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:
# Note with both explicit and inline relations
write_note(
title="Search Implementation",
content="# Search Component\\n\\n"
@@ -73,20 +74,6 @@ async def write_note(
"- depends_on [[Database Schema]]",
folder="docs/components"
)
# Note with tags
write_note(
title="Error Handling Design",
content="# Error Handling\\n\\n"
"This design builds on [[Reliability Design]].\\n\\n"
"## Approach\\n"
"- [design] Use error codes #architecture\\n"
"- [tech] Implement retry logic #implementation\\n\\n"
"## Relations\\n"
"- extends [[Base Error Handling]]",
folder="docs/design",
tags=["architecture", "reliability"]
)
"""
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
@@ -101,12 +88,43 @@ 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 note content by title, permalink, relation, or pattern")
-1
View File
@@ -158,7 +158,6 @@ async def test_get_resource_entities(client, test_config, entity_repository):
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
relation = entity2.relations[0]
# Test getting the content via the relation
response = await client.get("/resource/test/*")
-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
Generated
+1 -1
View File
@@ -70,7 +70,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.4.2"
version = "0.4.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },