mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b46908b1 | |||
| 0689e7a730 |
@@ -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
@@ -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,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}")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/*")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user