feat: add memory-json importer, tweak observation content

This commit is contained in:
phernandez
2025-02-04 10:59:28 -06:00
parent 91b1872eb7
commit 3484e26631
16 changed files with 337 additions and 211 deletions
+3 -7
View File
@@ -1,9 +1,5 @@
"""CLI commands package."""
"""Command module exports."""
from . import init, status
from . import init, status, sync, import_memory_json
__all__ = [
"init",
"status",
]
__all__ = ["init", "status", "sync", "import_memory_json.py"]
@@ -0,0 +1,139 @@
"""Import command for basic-memory CLI to import from JSON memory format."""
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import typer
from loguru import logger
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
from basic_memory.cli.app import app
from basic_memory.config import config
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation, Relation
console = Console()
async def process_memory_json(json_path: Path, base_path: Path,markdown_processor: MarkdownProcessor):
"""Import entities from memory.json using markdown processor."""
# First pass - collect all relations by source entity
entity_relations: Dict[str, List[Relation]] = {}
entities: Dict[str, Dict[str, Any]] = {}
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console,
) as progress:
read_task = progress.add_task("Reading memory.json...", total=None)
# First pass - collect entities and relations
with open(json_path) as f:
lines = f.readlines()
progress.update(read_task, total=len(lines))
for line in lines:
data = json.loads(line)
if data["type"] == "entity":
entities[data["name"]] = data
elif data["type"] == "relation":
# Store relation with its source entity
source = data["from"]
if source not in entity_relations:
entity_relations[source] = []
entity_relations[source].append(
Relation(
type=data["relationType"],
target=data["to"]
)
)
progress.update(read_task, advance=1)
# Second pass - create and write entities
write_task = progress.add_task("Creating entities...", total=len(entities))
entities_created = 0
for name, entity_data in entities.items():
entity = EntityMarkdown(
frontmatter=EntityFrontmatter(
metadata={
"type": entity_data["entityType"],
"title": name,
"permalink": f"{entity_data['entityType']}/{name}"
}
),
content=f"# {name}\n",
observations=[
Observation(content=obs)
for obs in entity_data["observations"]
],
relations=entity_relations.get(name, []) # Add any relations where this entity is the source
)
# Let markdown processor handle writing
file_path = base_path / f"{entity_data['entityType']}/{name}.md"
await markdown_processor.write_file(file_path, entity)
entities_created += 1
progress.update(write_task, advance=1)
return {
"entities": entities_created,
"relations": sum(len(rels) for rels in entity_relations.values())
}
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
@app.command()
def import_memory_json(
json_path: Path = typer.Argument(..., help="Path to memory.json file to import"),
):
"""Import entities and relations from a memory.json file.
This command will:
1. Read entities and relations from the JSON file
2. Create markdown files for each entity
3. Include outgoing relations in each entity's markdown
After importing, run 'basic-memory sync' to index the new files.
"""
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
try:
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home
console.print(f"\nImporting from {json_path}...writing to {base_path}")
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
# Show results
console.print(Panel(
f"[green]Import complete![/green]\n\n"
f"Created {results['entities']} entities\n"
f"Added {results['relations']} relations",
expand=False
))
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.exception("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
+2
View File
@@ -2,6 +2,7 @@
from basic_memory.file_utils import ParseError
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import (
EntityMarkdown,
EntityFrontmatter,
@@ -13,6 +14,7 @@ __all__ = [
"EntityMarkdown",
"EntityFrontmatter",
"EntityParser",
"MarkdownProcessor",
"Observation",
"Relation",
"ParseError",
+1 -1
View File
@@ -68,7 +68,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
return {
'category': category,
'content': ' '.join(content_parts).strip(),
'content': content,
'tags': list(tags) if tags else None,
'context': context
}
+1 -3
View File
@@ -9,15 +9,13 @@ from pydantic import BaseModel
class Observation(BaseModel):
"""An observation about an entity."""
category: Optional[str] = None
category: Optional[str] = "Note"
content: str
tags: Optional[List[str]] = None
context: Optional[str] = None
def __str__(self) -> str:
obs_string = f"- [{self.category}] {self.content}"
if self.tags:
obs_string += " " + " ".join(f"#{tag}" for tag in sorted(self.tags))
if self.context:
obs_string += f" ({self.context})"
return obs_string
+1 -1
View File
@@ -68,7 +68,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
assert len(entity.observations) == 1
assert entity.observations[0].category == "note"
assert entity.observations[0].content == "This is notable"
assert entity.observations[0].content == "This is notable #tag1"
assert entity.observations[0].tags == ["tag1"]
assert entity.observations[0].context == "testing"
+175
View File
@@ -0,0 +1,175 @@
"""Test import-json command functionality."""
import json
from io import StringIO
from pathlib import Path
import pytest
from rich.console import Console
from basic_memory.cli.commands.import_memory_json import process_memory_json
from basic_memory.markdown import EntityParser, MarkdownProcessor
@pytest.fixture
def console():
"""Create test console that captures output."""
output = StringIO()
return Console(file=output), output
@pytest.fixture
def sample_memory_json(tmp_path) -> Path:
"""Create a sample memory.json file with test data."""
json_path = tmp_path / "memory.json"
# Create test data modeling the real format
test_data = [
{
"type": "entity",
"name": "Basic_Memory",
"entityType": "software_system",
"observations": [
"A core component of Basic Machines",
"Local-first knowledge management system",
"Combines filesystem persistence with graph-based knowledge representation"
]
},
{
"type": "entity",
"name": "Basic_Machines",
"entityType": "project",
"observations": [
"Local-first knowledge management system",
"Focuses on enhancing human agency and understanding",
"Current focus includes basic-memory system"
]
},
{
"type": "relation",
"from": "Basic_Memory",
"to": "Basic_Machines",
"relationType": "is_component_of"
}
]
# Write each item as a JSON line
with open(json_path, 'w') as f:
for item in test_data:
f.write(json.dumps(item) + '\n')
return json_path
@pytest.mark.asyncio
async def test_process_memory_json(
sample_memory_json: Path,
markdown_processor: MarkdownProcessor,
test_config,
):
"""Test importing from memory.json format."""
# Process the import
results = await process_memory_json(sample_memory_json, test_config.home, markdown_processor)
# Check results
assert results["entities"] == 2
assert results["relations"] == 1
# Verify Basic_Memory entity file was created correctly
basic_memory_path = test_config.home / "software_system/Basic_Memory.md"
entity = await markdown_processor.read_file(basic_memory_path)
assert entity.frontmatter.title == "Basic_Memory"
assert entity.frontmatter.type == "software_system"
assert len(entity.observations) == 3
assert len(entity.relations) == 1 # Should have the outgoing relation
assert entity.relations[0].type == "is_component_of"
assert entity.relations[0].target == "Basic_Machines"
# Verify Basic_Machines entity file
basic_machines_path = test_config.home / "project/Basic_Machines.md"
entity = await markdown_processor.read_file(basic_machines_path)
assert entity.frontmatter.title == "Basic_Machines"
assert entity.frontmatter.type == "project"
assert len(entity.observations) == 3
assert len(entity.relations) == 0 # No outgoing relations
@pytest.mark.asyncio
async def test_process_memory_json_empty_observations(
tmp_path: Path,
markdown_processor: MarkdownProcessor,
test_config,
):
"""Test handling entities with no observations."""
# Create test data
json_path = tmp_path / "memory.json"
test_data = [
{
"type": "entity",
"name": "Empty_Entity",
"entityType": "test",
"observations": [] # Empty observations
}
]
with open(json_path, 'w') as f:
for item in test_data:
f.write(json.dumps(item) + '\n')
# Process import
results = await process_memory_json(json_path, test_config.home, markdown_processor)
# Check results
assert results["entities"] == 1
assert results["relations"] == 0
# Verify file was created
entity_path = test_config.home / "test/Empty_Entity.md"
entity = await markdown_processor.read_file(entity_path)
assert entity.frontmatter.title == "Empty_Entity"
assert entity.observations == []
assert entity.relations == []
@pytest.mark.asyncio
async def test_process_memory_json_special_characters(
tmp_path: Path,
markdown_processor: MarkdownProcessor,
test_config,
):
"""Test handling entities with special characters in text."""
# Create test data
json_path = tmp_path / "memory.json"
test_data = [
{
"type": "entity",
"name": "Special_Entity",
"entityType": "test",
"observations": [
"Contains *markdown* formatting",
"Has #hashtags and @mentions",
"Uses [square brackets] and {curly braces}"
]
}
]
with open(json_path, 'w') as f:
for item in test_data:
f.write(json.dumps(item) + '\n')
# Process import
results = await process_memory_json(json_path, test_config.home, markdown_processor)
assert results["entities"] == 1
# Verify file was created and content preserved
entity_path = test_config.home / "test/Special_Entity.md"
entity = await markdown_processor.read_file(entity_path)
assert len(entity.observations) == 3
assert entity.observations[0].content == "Contains *markdown* formatting"
assert entity.observations[1].content == "Has #hashtags and @mentions"
assert entity.observations[2].content == "Uses [square brackets] and {curly braces}"
-170
View File
@@ -1,170 +0,0 @@
# """Test status command functionality."""
#
# from io import StringIO
#
# import pytest
# from rich.console import Console
#
# from basic_memory.cli.commands.status import display_changes, run_status
# from basic_memory.sync.file_change_scanner import FileState
# from basic_memory.sync.utils import SyncReport
# from basic_memory.file_utils import compute_checksum
#
#
# @pytest.fixture
# def console():
# """Create test console that captures output."""
# output = StringIO()
# return Console(file=output), output
#
#
# @pytest.mark.asyncio
# async def test_display_no_changes(console):
# """Test display with no changes."""
# test_console, output = console
# changes = SyncReport()
# display_changes("Test Files", changes, verbose=False)
# assert "No changes" in output.getvalue()
#
#
# @pytest.mark.asyncio
# async def test_display_compact_changes(console):
# """Test compact display of changes."""
# test_console, output = console
# changes = SyncReport(
# new={"docs/new.md"},
# modified={"docs/mod.md"},
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# permalink="new/location.md", checksum="abc123", moved_from="old/location.md"
# )
# },
# )
# display_changes("Test Files", changes, verbose=False)
# output_text = output.getvalue()
#
# # Check directory summaries
# assert "docs/ +1 new" in output_text.replace(" ", " ")
# assert "docs/ ~1 modified" in output_text.replace(" ", " ")
# assert "old/ -1 deleted" in output_text.replace(" ", " ")
# assert "new/ ->1 moved" in output_text.replace(" ", " ")
#
#
# @pytest.mark.asyncio
# async def test_display_verbose_changes(console):
# """Test verbose display of changes."""
# test_console, output = console
# changes = SyncReport(
# new={"docs/new.md"},
# modified={"docs/mod.md"},
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# permalink="new/location.md",
# checksum="abc123def", # 8 chars for display
# moved_from="old/location.md",
# )
# },
# checksums={
# "docs/new.md": "def456789abcdef",
# "docs/mod.md": "ghi789abcdef123",
# },
# )
# display_changes("Test Files", changes, verbose=True)
# output_text = output.getvalue()
#
# # Verify sections
# assert "New Files" in output_text
# assert "Modified" in output_text
# assert "Deleted" in output_text
# assert "Moved" in output_text
#
# # Check file listings with checksums
# assert "new.md (def45678)" in output_text
# assert "mod.md (ghi78789)" in output_text
# assert "location.md (abc123de)" in output_text
#
#
# @pytest.mark.asyncio
# async def test_end_to_end_status(file_change_scanner, test_config, entity_repository):
# """Test complete status command with real files."""
# # Create test files in both knowledge and documents directories
# project_dir = test_config.home
#
# # Create knowledge files
# component_dir = project_dir / "component"
# component_dir.mkdir(exist_ok=True)
# component_path = component_dir / "test.md"
# component_path.write_text("test component")
#
# # Add some files to DB with different paths to test moves
# await entity_repository.create(
# {"permalink": "old/doc.md", "file_path": "old/doc.md", "checksum": "abc123"}
# )
#
# # Run status check
# await run_status(file_change_scanner, verbose=True)
#
# # Verify changes through sync service directly
# doc_changes = await file_change_scanner.find_knowledge_changes(project_dir)
# assert len(doc_changes.new) == 2 # test.md and nested.md
# assert "test.md" in doc_changes.new
# assert "subdir/nested.md" in doc_changes.new
# assert len(doc_changes.deleted) == 1 # old/doc.md
#
# knowledge_changes = await file_change_scanner.find_knowledge_changes(project_dir)
# assert len(knowledge_changes.new) == 1
# assert "component/test.md" in knowledge_changes.new
#
#
# @pytest.mark.asyncio
# async def test_status_with_case_changes(file_change_scanner, test_config, entity_repository):
# """Test status detection with case-sensitive path changes."""
# docs_dir = test_config.documents_dir
# docs_dir.mkdir(parents=True, exist_ok=True)
#
# # Create file with initial case
# content = "test content"
# original_path = "Test.md"
# orig_file = docs_dir / original_path
# orig_file.write_text(content)
# checksum = await compute_checksum(content)
#
# # Add to DB
# await entity_repository.create(
# {"permalink": original_path, "file_path": original_path, "checksum": checksum}
# )
#
# # Simulate case change in filesystem
# orig_file.rename(docs_dir / "test.md")
#
# # Check changes
# changes = await file_change_scanner.find_knowledge_changes(docs_dir)
# assert len(changes.moved) == 1
# assert "test.md" in changes.moved
# assert changes.moved["test.md"].moved_from == original_path
#
#
# @pytest.mark.asyncio
# async def test_status_with_spaces(file_change_scanner, test_config, entity_repository):
# """Test status handling files with spaces and special characters."""
# docs_dir = test_config.documents_dir
# docs_dir.mkdir(parents=True, exist_ok=True)
#
# # Create file with spaces
# content = "test content"
# path = "My Document.md"
# file_path = docs_dir / path
# file_path.write_text(content)
# checksum = await compute_checksum(content)
#
# # Add to DB with same path
# await entity_repository.create({"permalink": path, "file_path": path, "checksum": checksum})
#
# # Check changes
# changes = await file_change_scanner.find_knowledge_changes(docs_dir)
# assert not changes.modified # File unchanged
# assert not changes.moved # Path matches exactly
# assert not changes.deleted
# assert not changes.new
+2 -2
View File
@@ -65,7 +65,7 @@ async def test_parse_complete_file(test_config, entity_parser, valid_entity_cont
assert len(entity.observations) == 3
obs = entity.observations[0]
assert obs.category == "design"
assert obs.content == "Stateless authentication"
assert obs.content == "Stateless authentication #security #architecture"
assert set(obs.tags or []) == {"security", "architecture"}
assert obs.context == "JWT based"
@@ -174,7 +174,7 @@ async def test_parse_file_without_section_headers(test_config, entity_parser):
assert len(entity.observations) == 1
assert entity.observations[0].category == "note"
assert entity.observations[0].content == "Basic observation"
assert entity.observations[0].content == "Basic observation #test"
assert entity.observations[0].tags == ["test"]
assert len(entity.relations) == 2
+4 -4
View File
@@ -16,7 +16,7 @@ def test_observation_plugin():
obs = obs_token.meta['observation']
assert obs['category'] == 'design'
assert obs['content'] == 'Core feature'
assert obs['content'] == 'Core feature #important #mvp'
assert set(obs['tags']) == {'important', 'mvp'}
assert obs['context'] is None
@@ -26,7 +26,7 @@ def test_observation_plugin():
obs = obs_token.meta['observation']
assert obs['category'] == 'feature'
assert obs['content'] == 'Authentication system'
assert obs['content'] == 'Authentication system #security'
assert set(obs['tags']) == {'security'}
assert obs['context'] == 'Required for MVP'
@@ -36,7 +36,7 @@ def test_observation_plugin():
obs = obs_token.meta['observation']
assert obs['category'] is None
assert obs['content'] == 'Authentication system'
assert obs['content'] == 'Authentication system #security'
assert set(obs['tags']) == {'security'}
assert obs['context'] == 'Required for MVP'
@@ -61,7 +61,7 @@ def test_observation_edge_cases():
tokens = md.parse("- [code] Function (x) returns y #function")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['content'] == 'Function (x) returns y'
assert obs['content'] == 'Function (x) returns y #function'
assert obs['context'] is None
# Multiple hashtags together
+1 -1
View File
@@ -71,7 +71,7 @@ async def test_write_new_file_with_content(markdown_processor: MarkdownProcessor
content="# Custom Title\n\nMy content here.\nMultiple lines.",
observations=[
Observation(
content="Test observation",
content="Test observation #test",
category="tech",
tags=["test"],
context="test context",
+2 -16
View File
@@ -50,13 +50,13 @@ def test_complex_format():
obs = parse_observation(token)
assert obs["category"] == "complex test"
assert set(obs["tags"]) == {"tag1", "tag2", "tag3"}
assert obs["content"] == "This is with content"
assert obs["content"] == "This is #tag1#tag2 with #tag3 content"
# Pydantic model validation
observation = Observation.model_validate(obs)
assert observation.category == "complex test"
assert set(observation.tags) == {"tag1", "tag2", "tag3"}
assert observation.content == "This is with content"
assert observation.content == "This is #tag1#tag2 with #tag3 content"
def test_malformed_category():
@@ -91,20 +91,6 @@ def test_no_category():
assert observation.content == "No category"
def test_whitespace_handling():
"""Test handling of various whitespace in content."""
md = MarkdownIt().use(observation_plugin)
# Various whitespace in content
test_chars = {" ": "space", "\t": "tab", "\r": "return"}
for char, name in test_chars.items():
content = f"- [test] Content{char}with{char}{name}"
tokens = md.parse(content)
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
observation = Observation.model_validate(obs)
assert observation.content == f"Content with {name}"
def test_unicode_content():
"""Test handling of Unicode content."""
+1 -1
View File
@@ -44,7 +44,7 @@ async def test_unicode_content(tmp_path):
assert "🧪" in entity.content
# Verify Unicode in observations
assert any(o.content == "Emoji test 👍" for o in entity.observations)
assert any(o.content == "Emoji test 👍 #emoji #test" for o in entity.observations)
assert any(o.category == "中文" for o in entity.observations)
assert any(o.category == "русский" for o in entity.observations)
+2 -2
View File
@@ -326,7 +326,7 @@ See the [[Git Cheat Sheet]] for reference.
assert len(entity.observations) == 1
assert entity.observations[0].category == "design"
assert entity.observations[0].content == "Keep feature branches short-lived"
assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow"
assert set(entity.observations[0].tags) == {"git", "workflow"}
assert entity.observations[0].context == "Reduces merge conflicts"
@@ -416,7 +416,7 @@ See the [[Git Cheat Sheet]] for reference.
assert len(entity.observations) == 1
assert entity.observations[0].category == "design"
assert entity.observations[0].content == "Keep feature branches short-lived"
assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow"
assert set(entity.observations[0].tags) == {"git", "workflow"}
assert entity.observations[0].context == "Reduces merge conflicts"
+1 -1
View File
@@ -74,7 +74,7 @@ async def test_handle_file_moved(test_config, watch_service, sync_service, sampl
assert len(watch_service.state.recent_events) == 2
assert watch_service.state.synced_files == 1
event = watch_service.state.recent_events[0]
assert event.path == "test.md"
assert event.path == "test.md -> moved.md"
assert event.action == "moved"
assert event.status == "success"
+2 -2
View File
@@ -4,5 +4,5 @@ from basic_memory import __version__
def test_version():
"""Test version is set correctly"""
assert __version__ == "0.1.0"
"""Test version is set"""
assert __version__ is not None