Compare commits

...

9 Commits

Author SHA1 Message Date
semantic-release 9437a5f83b chore(release): 0.12.3 [skip ci] 2025-04-17 16:04:31 +00:00
phernandez 3c1cc346df fix: modify recent_activity args to be strings instead of enums
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-17 10:56:46 -05:00
phernandez 81616ab42e update CLAUDE.md
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-17 10:56:46 -05:00
phernandez 73ea91fe0d fix: add extra logic for permalink generation with mixed Latin unicode and Chinese characters
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-17 10:00:40 -05:00
andyxinweiminicloud 03d4e97b90 Fix: preserve Chinese characters in permalinks 2025-04-17 09:03:35 -05:00
Paul Hernandez 98622a7a47 Update README.md
fix example json for passing --project arg

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2025-04-16 07:50:12 -05:00
Amadeusz Wieczorek 54dfa08aba Update name of function search_notes in error output (#92)
Signed-off-by: Amadeusz Wieczorek <git@amadeusw.com>
2025-04-14 11:06:15 -05:00
semantic-release 9433065a57 chore(release): 0.12.2 [skip ci] 2025-04-08 16:27:42 +00:00
Paul Hernandez 2934176331 fix: utf8 for all file reads/write/open instead of default platform encoding (#91)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-08 10:55:01 -05:00
27 changed files with 370 additions and 144 deletions
+26
View File
@@ -1,6 +1,32 @@
# CHANGELOG
## v0.12.3 (2025-04-17)
### Bug Fixes
- Add extra logic for permalink generation with mixed Latin unicode and Chinese characters
([`73ea91f`](https://github.com/basicmachines-co/basic-memory/commit/73ea91fe0d1f7ab89b99a1b691d59fe608b7fcbb))
Signed-off-by: phernandez <paul@basicmachines.co>
- Modify recent_activity args to be strings instead of enums
([`3c1cc34`](https://github.com/basicmachines-co/basic-memory/commit/3c1cc346df519e703fae6412d43a92c7232c6226))
Signed-off-by: phernandez <paul@basicmachines.co>
## v0.12.2 (2025-04-08)
### Bug Fixes
- Utf8 for all file reads/write/open instead of default platform encoding
([#91](https://github.com/basicmachines-co/basic-memory/pull/91),
[`2934176`](https://github.com/basicmachines-co/basic-memory/commit/29341763318408ea8f1e954a41046c4185f836c6))
Signed-off-by: phernandez <paul@basicmachines.co>
## v0.12.1 (2025-04-07)
### Bug Fixes
+15 -1
View File
@@ -172,4 +172,18 @@ With GitHub integration, the development workflow includes:
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
With this integration, the AI assistant is a full-fledged team member rather than just a tool for generating code
snippets.
snippets.
### Basic Memory Pro
Basic Memory Pro is a desktop GUI application that wraps the basic-memory CLI/MCP tools:
- Built with Tauri (Rust), React (TypeScript), and a Python FastAPI sidecar
- Provides visual knowledge graph exploration and project management
- Uses the same core codebase but adds a desktop-friendly interface
- Project configuration is shared between CLI and Pro versions
- Multiple project support with visual switching interface
local repo: /Users/phernandez/dev/basicmachines/basic-memory-pro
github: https://github.com/basicmachines-co/basic-memory-pro
+2 -2
View File
@@ -333,9 +333,9 @@ config:
"command": "uvx",
"args": [
"basic-memory",
"mcp",
"--project",
"your-project-name"
"your-project-name",
"mcp"
]
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ def update_claude_config():
# Load existing config or create new
if config_path.exists():
config = json.loads(config_path.read_text())
config = json.loads(config_path.read_text(encoding="utf-8"))
else:
config = {"mcpServers": {}}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.12.1"
version = "0.12.3"
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.12.1"
__version__ = "0.12.3"
@@ -38,7 +38,7 @@ async def process_memory_json(
read_task = progress.add_task("Reading memory.json...", total=None)
# First pass - collect entities and relations
with open(json_path) as f:
with open(json_path, encoding="utf-8") as f:
lines = f.readlines()
progress.update(read_task, total=len(lines))
+1 -1
View File
@@ -104,7 +104,7 @@ class EntityParser:
absolute_path = self.base_path / path
# Parse frontmatter and content using python-frontmatter
file_content = absolute_path.read_text()
file_content = absolute_path.read_text(encoding="utf-8")
return await self.parse_file_content(absolute_path, file_content)
async def parse_file_content(self, absolute_path, file_content):
+2 -2
View File
@@ -111,7 +111,7 @@ def format_not_found_message(identifier: str) -> str:
## Search Instead
Try searching for related content:
```
search(query="{identifier}")
search_notes(query="{identifier}")
```
## Recent Activity
@@ -172,7 +172,7 @@ def format_related_results(identifier: str, results) -> str:
## Search For More Results
To see more related content:
```
search(query="{identifier}")
search_notes(query="{identifier}")
```
## Create New Note
+37 -15
View File
@@ -1,6 +1,6 @@
"""Recent activity tool for Basic Memory MCP server."""
from typing import Optional, List
from typing import List, Union
from loguru import logger
@@ -14,7 +14,7 @@ from basic_memory.schemas.search import SearchItemType
@mcp.tool(
description="""Get recent activity from across the knowledge base.
Timeframe supports natural language formats like:
- "2 days ago"
- "last week"
@@ -25,9 +25,9 @@ from basic_memory.schemas.search import SearchItemType
""",
)
async def recent_activity(
type: Optional[List[SearchItemType]] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
type: Union[str, List[str]] = "",
depth: int = 1,
timeframe: TimeFrame = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
@@ -35,11 +35,14 @@ async def recent_activity(
"""Get recent activity across the knowledge base.
Args:
type: Filter by content type(s). Valid options:
- ["entity"] for knowledge entities
- ["relation"] for connections between entities
- ["observation"] for notes and observations
type: Filter by content type(s). Can be a string or list of strings.
Valid options:
- "entity" or ["entity"] for knowledge entities
- "relation" or ["relation"] for connections between entities
- "observation" or ["observation"] for notes and observations
Multiple types can be combined: ["entity", "relation"]
Case-insensitive: "ENTITY" and "entity" are treated the same.
Default is an empty string, which returns all types.
depth: How many relation hops to traverse (1-3 recommended)
timeframe: Time window to search. Supports natural language:
- Relative: "2 days ago", "last week", "yesterday"
@@ -59,14 +62,17 @@ async def recent_activity(
# Get all entities for the last 10 days (default)
recent_activity()
# Get all entities from yesterday
# Get all entities from yesterday (string format)
recent_activity(type="entity", timeframe="yesterday")
# Get all entities from yesterday (list format)
recent_activity(type=["entity"], timeframe="yesterday")
# Get recent relations and observations
recent_activity(type=["relation", "observation"], timeframe="today")
# Look back further with more context
recent_activity(type=["entity"], depth=2, timeframe="2 weeks ago")
recent_activity(type="entity", depth=2, timeframe="2 weeks ago")
Notes:
- Higher depth values (>3) may impact performance with large result sets
@@ -86,11 +92,27 @@ async def recent_activity(
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
# send enum values if we have an enum, else send string value
# Validate and convert type parameter
if type:
params["type"] = [ # pyright: ignore
type.value if isinstance(type, SearchItemType) else type for type in type
]
# Convert single string to list
if isinstance(type, str):
type_list = [type]
else:
type_list = type
# Validate each type against SearchItemType enum
validated_types = []
for t in type_list:
try:
# Try to convert string to enum
if isinstance(t, str):
validated_types.append(SearchItemType(t.lower()))
except ValueError:
valid_types = [t.value for t in SearchItemType]
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
response = await call_get(
client,
+3 -5
View File
@@ -1,9 +1,8 @@
"""Service for syncing files between filesystem and database."""
import os
from dataclasses import dataclass
from dataclasses import field
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Set, Tuple
@@ -18,7 +17,6 @@ from basic_memory.models import Entity
from basic_memory.repository import EntityRepository, RelationRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.search_service import SearchService
import time
@dataclass
@@ -237,7 +235,7 @@ class SyncService:
logger.debug(f"Parsing markdown file, path: {path}, new: {new}")
file_path = self.entity_parser.base_path / path
file_content = file_path.read_text()
file_content = file_path.read_text(encoding="utf-8")
file_contains_frontmatter = has_frontmatter(file_content)
# entity markdown will always contain front matter, so it can be used up create/update the entity
+67 -17
View File
@@ -5,11 +5,11 @@ import os
import logging
import re
import sys
import unicodedata
from pathlib import Path
from typing import Optional, Protocol, Union, runtime_checkable, List
from typing import Optional, Protocol, Union, runtime_checkable, List, Any
from loguru import logger
from unidecode import unidecode
@runtime_checkable
@@ -27,23 +27,23 @@ FilePath = Union[Path, str]
logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path (str, Path, or PathLike)
def generate_permalink(file_path: Union[Path, str, Any]) -> str:
"""
Generate a permalink from a file path.
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
to hyphens for consistency.
to hyphens for consistency. Preserves non-ASCII characters like Chinese.
Examples:
>>> generate_permalink("docs/My Feature.md")
'docs/my-feature'
>>> generate_permalink("specs/API (v2).md")
>>> generate_permalink("specs/API_v2.md")
'specs/api-v2'
>>> generate_permalink("design/unified_model_refactor.md")
'design/unified-model-refactor'
>>> generate_permalink("中文/测试文档.md")
'中文/测试文档'
"""
# Convert Path to string if needed
path_str = str(file_path)
@@ -51,24 +51,74 @@ def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
# Remove extension
base = os.path.splitext(path_str)[0]
# Transliterate unicode to ascii
ascii_text = unidecode(base)
# Create a transliteration mapping for specific characters
transliteration_map = {
"ø": "o", # Handle Søren -> soren
"å": "a", # Handle Kierkegård -> kierkegard
"ü": "u", # Handle Müller -> muller
"é": "e", # Handle Café -> cafe
"è": "e", # Handle Mère -> mere
"ê": "e", # Handle Fête -> fete
"à": "a", # Handle À la mode -> a la mode
"ç": "c", # Handle Façade -> facade
"ñ": "n", # Handle Niño -> nino
"ö": "o", # Handle Björk -> bjork
"ä": "a", # Handle Häagen -> haagen
# Add more mappings as needed
}
# Process character by character, transliterating Latin characters with diacritics
result = ""
for char in base:
# Direct mapping for known characters
if char.lower() in transliteration_map:
result += transliteration_map[char.lower()]
# General case using Unicode normalization
elif unicodedata.category(char).startswith("L") and ord(char) > 127:
# Decompose the character (e.g., ü -> u + combining diaeresis)
decomposed = unicodedata.normalize("NFD", char)
# If decomposition produced multiple characters and first one is ASCII
if len(decomposed) > 1 and ord(decomposed[0]) < 128:
# Keep only the base character
result += decomposed[0].lower()
else:
# For non-Latin scripts like Chinese, preserve the character
result += char
else:
# Add the character as is
result += char
# Handle special punctuation cases for apostrophes
result = result.replace("'", "")
# Insert dash between camelCase
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
# This regex finds boundaries between lowercase and uppercase letters
result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
# Convert to lowercase
lower_text = ascii_text.lower()
# Insert dash between Chinese and Latin character boundaries
# This is needed for cases like "中文English" -> "中文-english"
result = re.sub(r"([\u4e00-\u9fff])([a-zA-Z])", r"\1-\2", result)
result = re.sub(r"([a-zA-Z])([\u4e00-\u9fff])", r"\1-\2", result)
# replace underscores with hyphens
# Convert ASCII letters to lowercase, preserve non-ASCII characters
lower_text = "".join(c.lower() if c.isascii() and c.isalpha() else c for c in result)
# Replace underscores with hyphens
text_with_hyphens = lower_text.replace("_", "-")
# Replace remaining invalid chars with hyphens
clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_with_hyphens)
# Replace spaces and unsafe ASCII characters with hyphens, but preserve non-ASCII characters
# Include common Chinese character ranges and other non-ASCII characters
clean_text = re.sub(
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_with_hyphens
)
# Collapse multiple hyphens
clean_text = re.sub(r"-+", "-", clean_text)
# Remove hyphens between adjacent Chinese characters only
# This handles cases like "你好-世界" -> "你好世界"
clean_text = re.sub(r"([\u4e00-\u9fff])-([\u4e00-\u9fff])", r"\1\2", clean_text)
# Clean each path segment
segments = clean_text.split("/")
clean_segments = [s.strip("-") for s in segments]
+3 -3
View File
@@ -2,9 +2,9 @@
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from pathlib import Path
from basic_memory.schemas import EntityResponse
@@ -346,7 +346,7 @@ async def test_put_resource_new_file(client, test_config, entity_repository, sea
assert full_path.exists()
# Verify file content
file_content = full_path.read_text()
file_content = full_path.read_text(encoding="utf-8")
assert json.loads(file_content) == canvas_data
# Verify entity was created in DB
@@ -420,7 +420,7 @@ async def test_put_resource_update_existing(client, test_config, entity_reposito
assert response.status_code == 200
# Verify file was updated
updated_content = full_path.read_text()
updated_content = full_path.read_text(encoding="utf-8")
assert json.loads(updated_content) == updated_data
# Verify entity was updated
+8 -7
View File
@@ -1,10 +1,11 @@
"""Tests for import_chatgpt command."""
import json
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import import_app, app
from basic_memory.cli.app import app, import_app
from basic_memory.cli.commands import import_chatgpt
from basic_memory.config import config
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -144,7 +145,7 @@ def sample_conversation_with_hidden():
def sample_chatgpt_json(tmp_path, sample_conversation):
"""Create a sample ChatGPT JSON file."""
json_file = tmp_path / "conversations.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([sample_conversation], f)
return json_file
@@ -167,7 +168,7 @@ async def test_process_chatgpt_json(tmp_path, sample_chatgpt_json):
assert conv_path.exists()
# Check content formatting
content = conv_path.read_text()
content = conv_path.read_text(encoding="utf-8")
assert "# Test Conversation" in content
assert "### User" in content
assert "Hello, this is a test message" in content
@@ -183,14 +184,14 @@ async def test_process_code_blocks(tmp_path, sample_conversation_with_code):
# Create test file
json_file = tmp_path / "code_test.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([sample_conversation_with_code], f)
await import_chatgpt.process_chatgpt_json(json_file, tmp_path, processor)
# Check content
conv_path = tmp_path / "20250111-code-test.md"
content = conv_path.read_text()
content = conv_path.read_text(encoding="utf-8")
assert "```python" in content
assert "def hello():" in content
assert "```" in content
@@ -204,7 +205,7 @@ async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
# Create test file
json_file = tmp_path / "hidden_test.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([sample_conversation_with_hidden], f)
results = await import_chatgpt.process_chatgpt_json(json_file, tmp_path, processor)
@@ -214,7 +215,7 @@ async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
# Check content
conv_path = tmp_path / "20250111-hidden-test.md"
content = conv_path.read_text()
content = conv_path.read_text(encoding="utf-8")
assert "Visible message" in content
assert "Hidden message" not in content
@@ -1,6 +1,7 @@
"""Tests for import_claude command (chat conversations)."""
import json
import pytest
from typer.testing import CliRunner
@@ -44,7 +45,7 @@ def sample_conversation():
def sample_conversations_json(tmp_path, sample_conversation):
"""Create a sample conversations.json file."""
json_file = tmp_path / "conversations.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([sample_conversation], f)
return json_file
@@ -65,7 +66,7 @@ async def test_process_chat_json(tmp_path, sample_conversations_json):
# Check conversation file
conv_path = tmp_path / "20250105-test-conversation.md"
assert conv_path.exists()
content = conv_path.read_text()
content = conv_path.read_text(encoding="utf-8")
# Check content formatting
assert "### Human" in content
@@ -156,7 +157,7 @@ def test_import_conversation_with_attachments(tmp_path):
}
json_file = tmp_path / "with_attachments.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([conversation], f)
# Set up environment
@@ -168,7 +169,7 @@ def test_import_conversation_with_attachments(tmp_path):
# Check attachment formatting
conv_path = tmp_path / "conversations/20250105-test-with-attachments.md"
content = conv_path.read_text()
content = conv_path.read_text(encoding="utf-8")
assert "**Attachment: test.txt**" in content
assert "```" in content
assert "Test file content" in content
+5 -4
View File
@@ -1,6 +1,7 @@
"""Tests for import_claude_projects command."""
import json
import pytest
from typer.testing import CliRunner
@@ -43,7 +44,7 @@ def sample_project():
def sample_projects_json(tmp_path, sample_project):
"""Create a sample projects.json file."""
json_file = tmp_path / "projects.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([sample_project], f)
return json_file
@@ -70,14 +71,14 @@ async def test_process_projects_json(tmp_path, sample_projects_json):
# Check document files
doc1 = project_dir / "docs/test-document.md"
assert doc1.exists()
content1 = doc1.read_text()
content1 = doc1.read_text(encoding="utf-8")
assert "# Test Document" in content1
assert "This is test content" in content1
# Check prompt template
prompt = project_dir / "prompt-template.md"
assert prompt.exists()
prompt_content = prompt.read_text()
prompt_content = prompt.read_text(encoding="utf-8")
assert "# Test Prompt" in prompt_content
assert "This is a test prompt" in prompt_content
@@ -160,7 +161,7 @@ def test_import_project_without_prompt(tmp_path):
}
json_file = tmp_path / "no_prompt.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
json.dump([project], f)
# Set up environment
+4 -3
View File
@@ -1,6 +1,7 @@
"""Tests for import_memory_json command."""
import json
import pytest
from typer.testing import CliRunner
@@ -35,7 +36,7 @@ def sample_entities():
def sample_json_file(tmp_path, sample_entities):
"""Create a sample memory.json file."""
json_file = tmp_path / "memory.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
for entity in sample_entities:
f.write(json.dumps(entity) + "\n")
return json_file
@@ -55,7 +56,7 @@ async def test_process_memory_json(tmp_path, sample_json_file):
# Check file was created
entity_file = tmp_path / "test/test_entity.md"
assert entity_file.exists()
content = entity_file.read_text()
content = entity_file.read_text(encoding="utf-8")
assert "Test observation 1" in content
assert "Test observation 2" in content
assert "test_relation [[related_entity]]" in content
@@ -120,7 +121,7 @@ def test_import_json_command_handle_old_format(tmp_path):
]
json_file = tmp_path / "old_format.json"
with open(json_file, "w") as f:
with open(json_file, "w", encoding="utf-8") as f:
for item in old_format:
f.write(json.dumps(item) + "\n")
+5 -5
View File
@@ -8,10 +8,10 @@ from pathlib import Path
import pytest
from basic_memory.markdown.markdown_processor import MarkdownProcessor, DirtyFileError
from basic_memory.markdown.markdown_processor import DirtyFileError, MarkdownProcessor
from basic_memory.markdown.schemas import (
EntityMarkdown,
EntityFrontmatter,
EntityMarkdown,
Observation,
Relation,
)
@@ -41,7 +41,7 @@ async def test_write_new_minimal_file(markdown_processor: MarkdownProcessor, tmp
await markdown_processor.write_file(path, markdown)
# Read back and verify
content = path.read_text()
content = path.read_text(encoding="utf-8")
assert "---" in content # Has frontmatter
assert "type: note" in content
assert "permalink: test" in content
@@ -90,7 +90,7 @@ async def test_write_new_file_with_content(markdown_processor: MarkdownProcessor
await markdown_processor.write_file(path, markdown)
# Read back and verify
content = path.read_text()
content = path.read_text(encoding="utf-8")
# Check content preserved exactly
assert "# Custom Title" in content
@@ -169,7 +169,7 @@ async def test_dirty_file_detection(markdown_processor: MarkdownProcessor, tmp_p
checksum = await markdown_processor.write_file(path, initial)
# Modify file directly
path.write_text(path.read_text() + "\nModified!")
path.write_text(path.read_text(encoding="utf-8") + "\nModified!")
# Try to update with old checksum
update = EntityMarkdown(
@@ -5,12 +5,9 @@ from datetime import datetime
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import build_context, recent_activity
from basic_memory.mcp.tools import build_context
from basic_memory.schemas.memory import (
GraphContext,
EntitySummary,
ObservationSummary,
RelationSummary,
)
@@ -83,53 +80,6 @@ invalid_timeframes = [
]
@pytest.mark.asyncio
async def test_recent_activity_timeframe_formats(client, test_graph):
"""Test that recent_activity accepts various timeframe formats."""
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await recent_activity(
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
except Exception as e:
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await recent_activity(timeframe=timeframe)
@pytest.mark.asyncio
async def test_recent_activity_type_filters(client, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single type
result = await recent_activity(type=["entity"])
assert result is not None
assert all(isinstance(r, EntitySummary) for r in result.primary_results)
# Test multiple types
result = await recent_activity(type=["entity", "observation"])
assert result is not None
assert all(
isinstance(r, EntitySummary) or isinstance(r, ObservationSummary)
for r in result.primary_results
)
# Test all types
result = await recent_activity(type=["entity", "observation", "relation"])
assert result is not None
# Results can be any type
assert all(
isinstance(r, EntitySummary)
or isinstance(r, ObservationSummary)
or isinstance(r, RelationSummary)
for r in result.primary_results
)
@pytest.mark.asyncio
async def test_build_context_timeframe_formats(client, test_graph):
"""Test that build_context accepts various timeframe formats."""
+4 -4
View File
@@ -46,7 +46,7 @@ async def test_create_canvas(app, test_config):
assert file_path.exists()
# Verify content is correct
content = json.loads(file_path.read_text())
content = json.loads(file_path.read_text(encoding="utf-8"))
assert content["nodes"] == nodes
assert content["edges"] == edges
@@ -81,7 +81,7 @@ async def test_create_canvas_with_extension(app, test_config):
assert file_path.exists()
# Verify content
content = json.loads(file_path.read_text())
content = json.loads(file_path.read_text(encoding="utf-8"))
assert content["nodes"] == nodes
@@ -134,7 +134,7 @@ async def test_update_existing_canvas(app, test_config):
assert "Updated: visualizations/update-test.canvas" in result
# Verify content was updated
content = json.loads(file_path.read_text())
content = json.loads(file_path.read_text(encoding="utf-8"))
assert content["nodes"] == updated_nodes
assert content["edges"] == updated_edges
@@ -252,7 +252,7 @@ async def test_create_canvas_complex_content(app, test_config):
assert file_path.exists()
# Verify content is correct with all complex structures
content = json.loads(file_path.read_text())
content = json.loads(file_path.read_text(encoding="utf-8"))
assert len(content["nodes"]) == 4
assert len(content["edges"]) == 2
+1 -1
View File
@@ -267,7 +267,7 @@ async def test_read_note_text_search_fallback(mock_call_get, mock_search):
assert "Related Note 1" in result
assert "Related Note 2" in result
assert 'read_note("notes/related-note-1")' in result
assert "search(query=" in result
assert "search_notes(query=" in result
assert "write_note(" in result
+110
View File
@@ -0,0 +1,110 @@
"""Tests for discussion context MCP tool."""
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import recent_activity
from basic_memory.schemas.memory import (
EntitySummary,
ObservationSummary,
RelationSummary,
)
from basic_memory.schemas.search import SearchItemType
# Test data for different timeframe formats
valid_timeframes = [
"7d", # Standard format
"yesterday", # Natural language
"0d", # Zero duration
]
invalid_timeframes = [
"invalid", # Nonsense string
"tomorrow", # Future date
]
@pytest.mark.asyncio
async def test_recent_activity_timeframe_formats(client, test_graph):
"""Test that recent_activity accepts various timeframe formats."""
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await recent_activity(
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
except Exception as e:
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await recent_activity(timeframe=timeframe)
@pytest.mark.asyncio
async def test_recent_activity_type_filters(client, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single string type
result = await recent_activity(type=SearchItemType.ENTITY)
assert result is not None
assert all(isinstance(r, EntitySummary) for r in result.primary_results)
# Test single string type
result = await recent_activity(type="entity")
assert result is not None
assert all(isinstance(r, EntitySummary) for r in result.primary_results)
# Test single type
result = await recent_activity(type=["entity"])
assert result is not None
assert all(isinstance(r, EntitySummary) for r in result.primary_results)
# Test multiple types
result = await recent_activity(type=["entity", "observation"])
assert result is not None
assert all(
isinstance(r, EntitySummary) or isinstance(r, ObservationSummary)
for r in result.primary_results
)
# Test multiple types
result = await recent_activity(type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION])
assert result is not None
assert all(
isinstance(r, EntitySummary) or isinstance(r, ObservationSummary)
for r in result.primary_results
)
# Test all types
result = await recent_activity(type=["entity", "observation", "relation"])
assert result is not None
# Results can be any type
assert all(
isinstance(r, EntitySummary)
or isinstance(r, ObservationSummary)
or isinstance(r, RelationSummary)
for r in result.primary_results
)
@pytest.mark.asyncio
async def test_recent_activity_type_invalid(client, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single invalid string type
with pytest.raises(ValueError) as e:
await recent_activity(type="note")
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
# Test invalid string array type
with pytest.raises(ValueError) as e:
await recent_activity(type=["note"])
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
+2 -2
View File
@@ -144,14 +144,14 @@ async def test_rapid_atomic_writes(watch_service, test_config):
await asyncio.sleep(0.1)
# Read content to verify
content1 = final_path.read_text()
content1 = final_path.read_text(encoding="utf-8")
assert content1 == "First version"
# Simulate the second atomic write
tmp2_path.rename(final_path)
# Verify content was updated
content2 = final_path.read_text()
content2 = final_path.read_text(encoding="utf-8")
assert content2 == "Second version"
# Create a batch of changes that might arrive in mixed order
+1 -1
View File
@@ -63,7 +63,7 @@ async def test_write_status(watch_service):
await watch_service.write_status()
assert watch_service.status_path.exists()
data = json.loads(watch_service.status_path.read_text())
data = json.loads(watch_service.status_path.read_text(encoding="utf-8"))
assert not data["running"]
assert data["error_count"] == 0
+11 -11
View File
@@ -5,16 +5,16 @@ from pathlib import Path
import pytest
from basic_memory.file_utils import (
compute_checksum,
ensure_directory,
write_file_atomic,
parse_frontmatter,
has_frontmatter,
remove_frontmatter,
FileError,
FileWriteError,
ParseError,
compute_checksum,
ensure_directory,
has_frontmatter,
parse_frontmatter,
remove_frontmatter,
update_frontmatter,
write_file_atomic,
)
@@ -52,7 +52,7 @@ async def test_write_file_atomic(tmp_path: Path):
await write_file_atomic(test_file, content)
assert test_file.exists()
assert test_file.read_text() == content
assert test_file.read_text(encoding="utf-8") == content
# Temp file should be cleaned up
assert not test_file.with_suffix(".tmp").exists()
@@ -185,7 +185,7 @@ async def test_update_frontmatter(tmp_path: Path):
checksum = await update_frontmatter(test_file, updates)
# Verify content
updated = test_file.read_text()
updated = test_file.read_text(encoding="utf-8")
assert "title: Test" in updated
assert "type: note" in updated
assert "Test Content" in updated
@@ -204,13 +204,13 @@ async def test_update_frontmatter(tmp_path: Path):
assert new_checksum != checksum
# Verify content
updated = test_file.read_text()
updated = test_file.read_text(encoding="utf-8")
fm = parse_frontmatter(updated)
assert fm == {"title": "Test", "type": "doc", "tags": ["test"]}
assert "Test Content" in updated
# Test 3: Update with empty dict shouldn't change anything
checksum_before = await compute_checksum(test_file.read_text())
checksum_before = await compute_checksum(test_file.read_text(encoding="utf-8"))
new_checksum = await update_frontmatter(test_file, {})
assert new_checksum == checksum_before
@@ -229,7 +229,7 @@ More content here"""
test_file.write_text(content)
await update_frontmatter(test_file, {"title": "Test"})
updated = test_file.read_text()
updated = test_file.read_text(encoding="utf-8")
assert remove_frontmatter(updated).strip() == content
+52
View File
@@ -7,6 +7,7 @@ import pytest
from basic_memory.config import ProjectConfig
from basic_memory.services import EntityService
from basic_memory.sync.sync_service import SyncService
from basic_memory.utils import generate_permalink
async def create_test_file(path: Path, content: str = "test content") -> None:
@@ -66,3 +67,54 @@ Testing permalink generation.
assert entity.permalink == expected_permalink, (
f"File {filename} should have permalink {expected_permalink}"
)
@pytest.mark.parametrize(
"input_path, expected",
[
("test/Über File.md", "test/uber-file"),
("docs/résumé.md", "docs/resume"),
("notes/Déjà vu.md", "notes/deja-vu"),
("papers/Jürgen's Findings.md", "papers/jurgens-findings"),
("archive/François Müller.md", "archive/francois-muller"),
("research/Søren Kierkegård.md", "research/soren-kierkegard"),
("articles/El Niño.md", "articles/el-nino"),
],
)
def test_latin_accents_transliteration(input_path, expected):
"""Test that Latin letters with accents are properly transliterated."""
assert generate_permalink(input_path) == expected
@pytest.mark.parametrize(
"input_path, expected",
[
("中文/测试文档.md", "中文/测试文档"),
("notes/北京市.md", "notes/北京市"),
("research/上海简介.md", "research/上海简介"),
("docs/中文 English Mixed.md", "docs/中文-english-mixed"),
("articles/东京Tokyo混合.md", "articles/东京-tokyo-混合"),
("papers/汉字_underscore_test.md", "papers/汉字-underscore-test"),
("projects/中文CamelCase测试.md", "projects/中文-camel-case-测试"),
],
)
def test_chinese_character_preservation(input_path, expected):
"""Test that Chinese characters are preserved in permalinks."""
assert generate_permalink(input_path) == expected
@pytest.mark.parametrize(
"input_path, expected",
[
("mixed/北京Café.md", "mixed/北京-cafe"),
("notes/东京Tōkyō.md", "notes/东京-tokyo"),
("research/München中文.md", "research/munchen-中文"),
("docs/Über测试.md", "docs/uber-测试"),
("complex/北京Beijing上海Shanghai.md", "complex/北京-beijing-上海-shanghai"),
("special/中文!@#$%^&*()_+.md", "special/中文"),
("punctuation/你好,世界!.md", "punctuation/你好世界"),
],
)
def test_mixed_character_sets(input_path, expected):
"""Test handling of mixed character sets and edge cases."""
assert generate_permalink(input_path) == expected
Generated
+1 -1
View File
@@ -71,7 +71,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.12.0"
version = "0.12.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },