feat: Beta work (#17)

feat: Add multiple projects support 
feat: enhanced read_note for when initial result is not found
fix: merge frontmatter when updating note
fix: handle directory removed on sync watch
This commit is contained in:
Paul Hernandez
2025-03-05 18:46:04 -06:00
committed by GitHub
parent 41868fd34c
commit e6496df595
60 changed files with 3506 additions and 1223 deletions
-23
View File
@@ -1,23 +0,0 @@
name: DCO Check
on:
pull_request:
branches: [ main ]
jobs:
dco:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up DCO check
uses: dcoapp/app@v1.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
fail-on-error: true
require-all-contributors: true
require-signoff: true
+129
View File
@@ -13,6 +13,7 @@
██╔══██╗██╔══██║╚════██║██║██║ ██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║██╔══██╗ ╚██╔╝
██████╔╝██║ ██║███████║██║╚██████╗ ██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝██║ ██║ ██║
╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
```
Basic Memory lets you build persistent knowledge through natural conversations with Large Language Models (LLMs) like
@@ -355,6 +356,25 @@ for OS X):
}
```
If you want to use a specific project (see [Multiple Projects](#multiple-projects) below), update your Claude Desktop
config:
```json
{
"mcpServers": {
"basic-memory": {
"command": "uvx",
"args": [
"basic-memory",
"mcp",
"--project",
"your-project-name"
]
}
}
}
```
2. Sync your knowledge:
```bash
@@ -386,6 +406,56 @@ canvas(nodes, edges, title, folder) - Generate knowledge visualizations
"What have I been working on in the past week?"
```
## Multiple Projects
Basic Memory supports managing multiple separate knowledge bases through projects. This feature allows you to maintain
separate knowledge graphs for different purposes (e.g., personal notes, work projects, research topics).
### Managing Projects
```bash
# List all configured projects
basic-memory project list
# Add a new project
basic-memory project add work ~/work-basic-memory
# Set the default project
basic-memory project default work
# Remove a project (doesn't delete files)
basic-memory project remove personal
# Show current project
basic-memory project current
```
### Using Projects in Commands
All commands support the `--project` flag to specify which project to use:
```bash
# Sync a specific project
basic-memory --project=work sync
# Run MCP server for a specific project
basic-memory --project=personal mcp
```
You can also set the `BASIC_MEMORY_PROJECT` environment variable:
```bash
BASIC_MEMORY_PROJECT=work basic-memory sync
```
### Project Isolation
Each project maintains:
- Its own collection of markdown files in the specified directory
- A separate SQLite database for that project
- Complete knowledge graph isolation from other projects
## Design Philosophy
Basic Memory is built on some key ideas:
@@ -532,6 +602,65 @@ Basic Memory is flexible about how you organize your files:
The system will build the semantic knowledge graph regardless of your file organization preference.
## Using stdin with Basic Memory's `write_note` Tool
The `write-note` tool supports reading content from standard input (stdin), allowing for more flexible workflows when
creating or updating notes in your Basic Memory knowledge base.
### Use Cases
This feature is particularly useful for:
1. **Piping output from other commands** directly into Basic Memory notes
2. **Creating notes with multi-line content** without having to escape quotes or special characters
3. **Integrating with AI assistants** like Claude Code that can generate content and pipe it to Basic Memory
4. **Processing text data** from files or other sources
## Basic Usage
### Method 1: Using a Pipe
You can pipe content from another command into `write_note`:
```bash
# Pipe output of a command into a new note
echo "# My Note\n\nThis is a test note" | basic-memory tools write-note --title "Test Note" --folder "notes"
# Pipe output of a file into a new note
cat README.md | basic-memory tools write-note --title "Project README" --folder "documentation"
# Process text through other tools before saving as a note
cat data.txt | grep "important" | basic-memory tools write-note --title "Important Data" --folder "data"
```
### Method 2: Using Heredoc Syntax
For multi-line content, you can use heredoc syntax:
```bash
# Create a note with heredoc
cat << EOF | basic-memory tools write_note --title "Project Ideas" --folder "projects"
# Project Ideas for Q2
## AI Integration
- Improve recommendation engine
- Add semantic search to product catalog
## Infrastructure
- Migrate to Kubernetes
- Implement CI/CD pipeline
EOF
```
### Method 3: Input Redirection
You can redirect input from a file:
```bash
# Create a note from file content
basic-memory tools write-note --title "Meeting Notes" --folder "meetings" < meeting_notes.md
```
## License
AGPL-3.0
+326 -188
View File
@@ -1,275 +1,413 @@
# AI Assistant Guide
# AI Assistant Guide for Basic Memory
This guide explains how to use Basic Memory's tools effectively when working with users.
It explains how to read, write, and navigate knowledge through the Model Context Protocol (MCP).
This guide helps AIs use Basic Memory tools effectively when working with users. It covers reading, writing, and
navigating knowledge through the Model Context Protocol (MCP).
## Overview
Basic Memory allows users and LLMs to record context in local files using plain text Markdown formats to build a rich,
organized knowledge base through natural conversations and simple tools.
Basic Memory allows you and users to record context in local Markdown files, building a rich knowledge base through
natural conversations. The system automatically creates a semantic knowledge graph from simple text patterns.
- LLMs can read and write notes
- Users can see content in real time
- Simple Markdown formats are parsed to create a semantic knowledge graph
- All data is local and stored in plain text files on the user's computer
- Files can be updated externally and synced back to the knowledge base
- **Local-First**: All data is stored in plain text files on the user's computer
- **Real-Time**: Users see content updates immediately
- **Bi-Directional**: Both you and users can read and edit notes
- **Semantic**: Simple patterns create a structured knowledge graph
- **Persistent**: Knowledge persists across sessions and conversations
## Core Tools
## The Importance of the Knowledge Graph
Basic Memory provides several tools through the MCP (Model Context Protocol) for LLMs:
**Basic Memory's value comes from connections between notes, not just the notes themselves.**
When writing notes, your primary goal should be creating a rich, interconnected knowledge graph:
1. **Increase Semantic Density**: Add multiple observations and relations to each note
2. **Use Accurate References**: Aim to reference existing entities by their exact titles
3. **Create Forward References**: Feel free to reference entities that don't exist yet - Basic Memory will resolve these
when they're created later
4. **Create Bidirectional Links**: When appropriate, connect entities from both directions
5. **Use Meaningful Categories**: Add semantic context with appropriate observation categories
6. **Choose Precise Relations**: Use specific relation types that convey meaning
Remember: A knowledge graph with 10 heavily connected notes is more valuable than 20 isolated notes. Your job is to help
build these connections!
## Core Tools Reference
```python
# Writing knowledge
# Writing knowledge - THE MOST IMPORTANT TOOL!
response = await write_note(
title="Search Design",
content=content,
folder="specs",
tags=["search", "design"],
verbose=True # Get parsing details
title="Search Design", # Required: Note title
content="# Search Design\n...", # Required: Note content
folder="specs", # Optional: Folder to save in
tags=["search", "design"], # Optional: Tags for categorization
verbose=True # Optional: Get parsing details
)
# Reading knowledge
content = await read_note("Search Design") # By title
content = await read_note("specs/search") # By path
content = await read_note("memory://specs/search") # By memory url
content = await read_note("specs/search-design") # By path
content = await read_note("memory://specs/search") # By memory URL
# Building context
context = await build_context("memory://specs/search")
# Searching for knowledge
results = await search(
query="authentication system", # Text to search for
page=1, # Optional: Pagination
page_size=10 # Optional: Results per page
)
# Following relations
impl = await build_context("memory://specs/search/implements/*")
# Building context from the knowledge graph
context = await build_context(
url="memory://specs/search", # Starting point
depth=2, # Optional: How many hops to follow
timeframe="1 month" # Optional: Recent timeframe
)
# Checking changes
activity = await recent_activity(timeframe="1 week")
# Creating a json canvas diagram
activity = await canvas(...)
# Checking recent changes
activity = await recent_activity(
type="all", # Optional: Entity types to include
depth=1, # Optional: Related items to include
timeframe="1 week" # Optional: Time window
)
# Creating a knowledge visualization
canvas_result = await canvas(
nodes=[{"id": "note1", "label": "Search Design"}], # Nodes to display
edges=[{"from": "note1", "to": "note2"}], # Connections
title="Project Overview", # Canvas title
folder="diagrams" # Storage location
)
```
## Semantic Markup in Plain Text
## memory:// URLs Explained
Knowledge is encoded within standard markdown using semantic conventions that are both human-readable and
machine-processable.
Basic Memory uses a special URL format to reference entities in the knowledge graph:
**Key aspects:**
- `memory://title` - Reference by title
- `memory://folder/title` - Reference by folder and title
- `memory://permalink` - Reference by permalink
- `memory://path/relation_type/*` - Follow all relations of a specific type
- `memory://path/*/target` - Find all entities with relations to target
- Files in the knowledge base are each an `Entity` within the system
- Markdown files can contain semantic content through simple markup.
- `Observations` as categorized list items
- `Relations` as wiki-style links with types
- Frontmatter for metadata
- Minimal specialized syntax
## Semantic Markdown Format
**Examples:**
Knowledge is encoded in standard markdown using simple patterns:
- Observation syntax: `- [category] Content text #tag1 #tag2 (optional context)`
- Relation syntax: `- relation_type [[Entity]] (optional context)`
- Inline relations through `[[Entity]]` Wiki Link style references
**Observations** - Facts about an entity:
## Knowledge Graph Through Relations
```markdown
- [category] This is an observation #tag1 #tag2 (optional context)
```
Connections between documents create a knowledge graph without requiring a specialized database.
**Relations** - Links between entities:
**Key aspects:**
```markdown
- relation_type [[Target Entity]] (optional context)
```
- Relations create edges between document nodes
- Relation types provide semantic meaning to connections
- Navigation between knowledge via relation traversal
- Emergent structure through use
**Common Categories & Relation Types:**
**Examples:**
- Categories: `[idea]`, `[decision]`, `[question]`, `[fact]`, `[requirement]`, `[technique]`, `[recipe]`, `[preference]`
- Relations: `relates_to`, `implements`, `requires`, `extends`, `part_of`, `pairs_with`, `inspired_by`,
`originated_from`
- `implements`, `extends`, `relates_to` relations
- Following paths like `docs/search/implements/*`
- Context building by walking the graph
## When to Record Context
## Understanding Users
**Always consider recording context when**:
Users will interact in patterns like:
1. Users make decisions or reach conclusions
2. Important information emerges during conversation
3. Multiple related topics are discussed
4. The conversation contains information that might be useful later
5. Plans, tasks, or action items are mentioned
1. Creating knowledge:
**Protocol for recording context**:
1. Identify valuable information in the conversation
2. Ask the user: "Would you like me to record our discussion about [topic] in Basic Memory?"
3. If they agree, use `write_note` to capture the information
4. If they decline, continue without recording
5. Let the user know when information has been recorded: "I've saved our discussion about [topic] to Basic Memory."
## Understanding User Interactions
Users will interact with Basic Memory in patterns like:
1. **Creating knowledge**:
```
Human: "Let's write up what we discussed about search."
Response: I'll create a note capturing our discussion.
You: I'll create a note capturing our discussion about the search functionality.
[Use write_note() to record the conversation details]
```
AI Actions:
- record note via `write_note("...")`
1. Referencing existing knowledge:
2. **Referencing existing knowledge**:
```
Human: "Take a look at memory://specs/search"
Response: Let me build context from that and related documents.
You: I'll examine that information.
[Use build_context() to gather related information]
[Then read_note() to access specific content]
```
AI Actions:
- build context via `build_context("memory://specs/search")`
- examine results
- read content via `read_note()`
2. Finding information:
3. **Finding information**:
```
Human: "What were our decisions about auth?"
Response: I'll search for relevant notes and build context.
You: Let me find that information for you.
[Use search() to find relevant notes]
[Then build_context() to understand connections]
```
AI Actions:
- search via `search("auth")`
- examine results
- read content
## Key Things to Remember
3. **Files are Truth**
- Everything lives in local files
- Users control their files
- Always check verbose output
- The user can update files locally outside the LLM
- Changes need to be synced by the user
1. **Files are Truth**
- All knowledge lives in local files on the user's computer
- Users can edit files outside your interaction
- Changes need to be synced by the user (usually automatic)
- Always verify information is current with `recent_activity()`
4. **Building Context**
- Start specific
- Follow relations
2. **Building Context Effectively**
- Start with specific entities
- Follow meaningful relations
- Check recent changes
- Build incrementally
- Build context incrementally
- Combine related information
5. **Writing Knowledge**
- Using the same title + folder will overwrite a note
- Use semantic markup
- Create useful relations
- Keep files organized
3. **Writing Knowledge Wisely**
- Using the same title+folder will overwrite existing notes
- Structure content with clear headings and sections
- Use semantic markup for observations and relations
- Keep files organized in logical folders
## Common Patterns
## Common Knowledge Patterns
### Capturing Discussions
### Capturing Decisions
```markdown
# Coffee Brewing Methods
## Context
I've experimented with various brewing methods including French press, pour over, and espresso.
## Decision
Pour over is my preferred method for light to medium roasts because it highlights subtle flavors and offers more control
over the extraction.
## Observations
- [technique] Blooming the coffee grounds for 30 seconds improves extraction #brewing
- [preference] Water temperature between 195-205°F works best #temperature
- [equipment] Gooseneck kettle provides better control of water flow #tools
## Relations
- pairs_with [[Light Roast Beans]]
- contrasts_with [[French Press Method]]
- requires [[Proper Grinding Technique]]
```
### Recording Project Structure
```markdown
# Garden Planning
## Overview
This document outlines the garden layout and planting strategy for this season.
## Observations
- [structure] Raised beds in south corner for sun exposure #layout
- [structure] Drip irrigation system installed for efficiency #watering
- [pattern] Companion planting used to deter pests naturally #technique
## Relations
- contains [[Vegetable Section]]
- contains [[Herb Garden]]
- implements [[Organic Gardening Principles]]
```
### Technical Discussions
```markdown
# Recipe Improvement Discussion
## Key Points
Discussed strategies for improving the chocolate chip cookie recipe.
## Observations
- [issue] Cookies spread too thin when baked at 350°F #texture
- [solution] Chilling dough for 24 hours improves flavor and reduces spreading #technique
- [decision] Will use brown butter instead of regular butter #flavor
## Relations
- improves [[Basic Cookie Recipe]]
- inspired_by [[Bakery-Style Cookies]]
- pairs_with [[Homemade Ice Cream]]
```
### Creating Effective Relations
When creating relations, you can:
1. Reference existing entities by their exact title
2. Create forward references to entities that don't exist yet
```python
# Document a decision
response = await write_note(
title="Auth System Decision",
folder="decisions",
content="""# Auth System Decision
# Example workflow for creating notes with effective relations
async def create_note_with_effective_relations():
# Search for existing entities to reference
search_results = await search("travel")
existing_entities = [result.title for result in search_results.primary_results]
# Check if specific entities exist
packing_tips_exists = "Packing Tips" in existing_entities
japan_travel_exists = "Japan Travel Guide" in existing_entities
# Prepare relations section - include both existing and forward references
relations_section = "## Relations\n"
# Existing reference - exact match to known entity
if packing_tips_exists:
relations_section += "- references [[Packing Tips]]\n"
else:
# Forward reference - will be linked when that entity is created later
relations_section += "- references [[Packing Tips]]\n"
# Another possible reference
if japan_travel_exists:
relations_section += "- part_of [[Japan Travel Guide]]\n"
# You can also check recently modified notes to reference them
recent = await recent_activity(timeframe="1 week")
recent_titles = [item.title for item in recent.primary_results]
if "Transportation Options" in recent_titles:
relations_section += "- relates_to [[Transportation Options]]\n"
# Always include meaningful forward references, even if they don't exist yet
relations_section += "- located_in [[Tokyo]]\n"
relations_section += "- visited_during [[Spring 2023 Trip]]\n"
# Now create the note with both verified and forward relations
content = f"""# Tokyo Neighborhood Guide
## Context
Evaluated different auth approaches...
## Decision
Selected JWT-based authentication because...
## Observations
- [decision] Using JWT for auth #auth
- [tech] Implementing with bcrypt #security
## Relations
- affects [[Auth System]]
- based_on [[Security Requirements]]
## Overview
Details about different Tokyo neighborhoods and their unique characteristics.
## Observations
- [area] Shibuya is a busy shopping district #shopping
- [transportation] Yamanote Line connects major neighborhoods #transit
- [recommendation] Visit Shimokitazawa for vintage shopping #unique
- [tip] Get a Suica card for easy train travel #convenience
{relations_section}
"""
)
```
### Building Understanding
```python
async def explore_topic(topic):
# Get main context
context = await build_context(f"memory://{topic}")
# Find implementations
impl = await build_context(
f"memory://{topic}/implements/*"
)
# Get recent changes
activity = await recent_activity(timeframe="1 week")
relevant = [r for r in activity.primary_results
if topic in r.permalink]
# Build comprehensive view
for result in relevant:
details = await build_context(
f"memory://{result.permalink}"
)
```
### Handling Files
```python
# Check before writing
try:
existing = await read_note("Search Design")
# Update existing
await write_note(
title="Search Design",
content=updated_content,
result = await write_note(
title="Tokyo Neighborhood Guide",
content=content,
verbose=True
)
except:
# Create new
await write_note(
title="Search Design",
content=new_content,
)
# You can check which relations were resolved and which are forward references
if result and 'relations' in result:
resolved = [r['to_name'] for r in result['relations'] if r.get('target_id')]
forward_refs = [r['to_name'] for r in result['relations'] if not r.get('target_id')]
print(f"Resolved relations: {resolved}")
print(f"Forward references that will be resolved later: {forward_refs}")
```
## Error Handling
Common issues to watch for:
6. **Missing Content**
1. **Missing Content**
```python
try:
content = await read_note("Document")
except:
# Try search
results = await search({"text": "Document"})
# Try search instead
results = await search("Document")
if results and results.primary_results:
# Found something similar
content = await read_note(results.primary_results[0].permalink)
```
7. **Unresolved Relations**
2. **Forward References (Unresolved Relations)**
```python
response = await write_note(..., verbose=True)
for relation in response['relations']:
if not relation['target']:
# Relation didn't resolve
# Might need sync
# Or target doesn't exist
# Check for forward references (unresolved relations)
forward_refs = []
for relation in response.get('relations', []):
if not relation.get('target_id'):
forward_refs.append(relation.get('to_name'))
if forward_refs:
# This is a feature, not an error! Inform the user about forward references
print(f"Note created with forward references to: {forward_refs}")
print("These will be automatically linked when those notes are created.")
# Optionally suggest creating those entities now
print("Would you like me to create any of these notes now to complete the connections?")
```
8. **Pattern Matching**
3. **Sync Issues**
```python
# If pattern fails, try:
# - More specific path
# - Direct lookup
# - Search instead
# - Recent activity
# If information seems outdated
activity = await recent_activity(timeframe="1 hour")
if not activity or not activity.primary_results:
print("It seems there haven't been recent updates. You might need to run 'basic-memory sync'.")
```
## Best Practices
1. **Read and write Notes as needed**
- Write notes to record context
- See what was parsed
- Check relations
- Verify changes
1. **Proactively Record Context**
- Offer to capture important discussions
- Record decisions, rationales, and conclusions
- Link to related topics
- Ask for permission first: "Would you like me to save our discussion about [topic]?"
- Confirm when complete: "I've saved our discussion to Basic Memory"
2. **Build Context Carefully**
- Start specific
- Follow logical paths
- Combine approaches
- Stay relevant
2. **Create a Rich Semantic Graph**
- **Add meaningful observations**: Include at least 3-5 categorized observations in each note
- **Create deliberate relations**: Connect each note to at least 2-3 related entities
- **Use existing entities**: Before creating a new relation, search for existing entities
- **Verify wikilinks**: When referencing `[[Entity]]`, use exact titles of existing notes
- **Check accuracy**: Use `search()` or `recent_activity()` to confirm entity titles
- **Use precise relation types**: Choose specific relation types that convey meaning (e.g., "implements" instead
of "relates_to")
- **Consider bidirectional relations**: When appropriate, create inverse relations in both entities
3. **Write Clean Content**
- Clear structure
- Good organization
- Useful relations
- Regular cleanup
3. **Structure Content Thoughtfully**
- Use clear, descriptive titles
- Organize with logical sections (Context, Decision, Implementation, etc.)
- Include relevant context and background
- Add semantic observations with appropriate categories
- Use a consistent format for similar types of notes
- Balance detail with conciseness
Built with ♥️ by Basic Machines
4. **Navigate Knowledge Effectively**
- Start with specific searches
- Follow relation paths
- Combine information from multiple sources
- Verify information is current
- Build a complete picture before responding
5. **Help Users Maintain Their Knowledge**
- Suggest organizing related topics
- Identify potential duplicates
- Recommend adding relations between topics
- Offer to create summaries of scattered information
- Suggest potential missing relations: "I notice this might relate to [topic], would you like me to add that
connection?"
Built with ♥️ b
y Basic Machines
+5
View File
@@ -1,3 +1,8 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Set this at the package level to ensure it's set before any modules import logfire
import os
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
__version__ = "0.8.0"
+4 -9
View File
@@ -1,6 +1,5 @@
"""Functions for managing database migrations."""
import asyncio
from pathlib import Path
from loguru import logger
from alembic.config import Config
@@ -10,20 +9,16 @@ from alembic import command
def get_alembic_config() -> Config: # pragma: no cover
"""Get alembic config with correct paths."""
migrations_path = Path(__file__).parent
alembic_ini = migrations_path.parent.parent.parent / "alembic.ini"
alembic_ini = migrations_path / "alembic.ini"
config = Config(alembic_ini)
config.set_main_option("script_location", str(migrations_path))
return config
async def reset_database(): # pragma: no cover
def reset_database(): # pragma: no cover
"""Drop and recreate all tables."""
logger.info("Resetting database...")
config = get_alembic_config()
def _reset(cfg):
command.downgrade(cfg, "base")
command.upgrade(cfg, "head")
await asyncio.get_event_loop().run_in_executor(None, _reset, config)
command.downgrade(config, "base")
command.upgrade(config, "head")
+12 -1
View File
@@ -1,5 +1,10 @@
"""FastAPI application for basic-memory knowledge graph API."""
# Suppress logfire warnings
import os
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
from contextlib import asynccontextmanager
import logfire
@@ -43,6 +48,12 @@ app.include_router(resource.router)
@app.exception_handler(Exception)
async def exception_handler(request, exc): # pragma: no cover
logger.exception(
f"An unhandled exception occurred for request '{request.url}', exception: {exc}"
"API unhandled exception",
url=str(request.url),
method=request.method,
client=request.client.host if request.client else None,
path=request.url.path,
error_type=type(exc).__name__,
error=str(exc),
)
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
@@ -33,7 +33,9 @@ async def create_entity(
search_service: SearchServiceDep,
) -> EntityResponse:
"""Create an entity."""
logger.info(f"request: create_entity with data={data}")
logger.info(
"API request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
)
entity = await entity_service.create_entity(data)
@@ -41,7 +43,13 @@ async def create_entity(
await search_service.index_entity(entity, background_tasks=background_tasks)
result = EntityResponse.model_validate(entity)
logger.info(f"response: create_entity with result={result}")
logger.info(
"API response",
endpoint="create_entity",
title=result.title,
permalink=result.permalink,
status_code=201,
)
return result
@@ -55,10 +63,23 @@ async def create_or_update_entity(
search_service: SearchServiceDep,
) -> EntityResponse:
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
logger.info(f"request: create_or_update_entity with permalink={permalink}, data={data}")
logger.info(
"API request",
endpoint="create_or_update_entity",
permalink=permalink,
entity_type=data.entity_type,
title=data.title,
)
# Validate permalink matches
if data.permalink != permalink:
logger.warning(
"API validation error",
endpoint="create_or_update_entity",
permalink=permalink,
data_permalink=data.permalink,
error="Permalink mismatch",
)
raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
# Try create_or_update operation
@@ -70,7 +91,12 @@ async def create_or_update_entity(
result = EntityResponse.model_validate(entity)
logger.info(
f"response: create_or_update_entity with result={result}, status_code={response.status_code}"
"API response",
endpoint="create_or_update_entity",
title=result.title,
permalink=result.permalink,
created=created,
status_code=response.status_code,
)
return result
+54 -3
View File
@@ -1,4 +1,5 @@
import asyncio
from typing import Optional
import typer
@@ -6,13 +7,63 @@ from basic_memory import db
from basic_memory.config import config
asyncio.run(db.run_migrations(config))
def version_callback(value: bool) -> None:
"""Show version and exit."""
if value: # pragma: no cover
import basic_memory
typer.echo(f"Basic Memory version: {basic_memory.__version__}")
raise typer.Exit()
app = typer.Typer(name="basic-memory")
import_app = typer.Typer()
app.add_typer(import_app, name="import")
@app.callback()
def app_callback(
project: Optional[str] = typer.Option(
None,
"--project",
"-p",
help="Specify which project to use",
envvar="BASIC_MEMORY_PROJECT",
),
version: Optional[bool] = typer.Option(
None,
"--version",
"-v",
help="Show version and exit.",
callback=version_callback,
is_eager=True,
),
) -> None:
"""Basic Memory - Local-first personal knowledge management."""
# We use the project option to set the BASIC_MEMORY_PROJECT environment variable
# The config module will pick this up when loading
if project: # pragma: no cover
import os
import importlib
from basic_memory import config as config_module
# Set the environment variable
os.environ["BASIC_MEMORY_PROJECT"] = project
# Reload the config module to pick up the new project
importlib.reload(config_module)
# Update the local reference
global config
from basic_memory.config import config as new_config
config = new_config
# Run database migrations
asyncio.run(db.run_migrations(config))
# Register sub-command groups
import_app = typer.Typer(help="Import data from various sources")
app.add_typer(import_app, name="import")
claude_app = typer.Typer()
import_app.add_typer(claude_app, name="claude")
+14 -2
View File
@@ -1,5 +1,17 @@
"""CLI commands for basic-memory."""
from . import status, sync, db, import_memory_json, mcp
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project
__all__ = ["status", "sync", "db", "import_memory_json", "mcp"]
__all__ = [
"status",
"sync",
"db",
"import_memory_json",
"mcp",
"import_claude_conversations",
"import_claude_projects",
"import_chatgpt",
"tool",
"project",
]
+10 -12
View File
@@ -1,7 +1,5 @@
"""Database management commands."""
import asyncio
import logfire
import typer
from loguru import logger
@@ -10,19 +8,19 @@ from basic_memory.alembic import migrations
from basic_memory.cli.app import app
@logfire.instrument()
@app.command()
def reset(
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
if typer.confirm("This will delete all data in your db. Are you sure?"):
logger.info("Resetting database...")
asyncio.run(migrations.reset_database())
if typer.confirm("This will delete all data in your db. Are you sure?"):
logger.info("Resetting database...")
migrations.reset_database()
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
+28 -30
View File
@@ -208,6 +208,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
@logfire.instrument(extract_args=False)
def import_chatgpt(
conversations_json: Annotated[
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
@@ -226,38 +227,35 @@ def import_chatgpt(
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
try:
if conversations_json:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
try:
if conversations_json:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / folder
console.print(
f"\nImporting chats from {conversations_json}...writing to {base_path}"
)
results = asyncio.run(
process_chatgpt_json(conversations_json, folder, markdown_processor)
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_chatgpt_json(conversations_json, folder, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -161,6 +161,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
@logfire.instrument(extract_args=False)
def import_claude(
conversations_json: Annotated[
Path, typer.Argument(..., help="Path to conversations.json file")
@@ -179,35 +180,34 @@ def import_claude(
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
try:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_conversations_json(conversations_json, base_path, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
try:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_conversations_json(conversations_json, base_path, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -144,6 +144,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
@logfire.instrument(extract_args=False)
def import_projects(
projects_json: Annotated[Path, typer.Argument(..., help="Path to projects.json file")] = Path(
"projects.json"
@@ -161,36 +162,35 @@ def import_projects(
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
try:
if projects_json:
if not projects_json.exists():
typer.echo(f"Error: File not found: {projects_json}", err=True)
raise typer.Exit(1)
try:
if projects_json:
if not projects_json.exists():
typer.echo(f"Error: File not found: {projects_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / base_folder if base_folder else config.home
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
results = asyncio.run(
process_projects_json(projects_json, base_path, markdown_processor)
# Process the file
base_path = config.home / base_folder if base_folder else config.home
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
results = asyncio.run(
process_projects_json(projects_json, base_path, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['documents']} project documents\n"
f"Imported {results['prompts']} prompt templates",
expand=False,
)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['documents']} project documents\n"
f"Imported {results['prompts']} prompt templates",
expand=False,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -99,6 +99,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
@import_app.command()
@logfire.instrument(extract_args=False)
def memory_json(
json_path: Annotated[Path, typer.Argument(..., help="Path to memory.json file")] = Path(
"memory.json"
@@ -114,33 +115,32 @@ def memory_json(
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
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())
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))
# 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,
)
# 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.")
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
+7 -1
View File
@@ -1,6 +1,8 @@
"""MCP server command."""
from loguru import logger
import basic_memory
from basic_memory.cli.app import app
from basic_memory.config import config
@@ -15,6 +17,10 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
def mcp(): # pragma: no cover
"""Run the MCP server for Claude Desktop integration."""
home_dir = config.home
project_name = config.project
logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
logger.info(f"Home directory: {home_dir}")
logger.info(f"Project: {project_name}")
logger.info(f"Project directory: {home_dir}")
mcp_server.run()
+119
View File
@@ -0,0 +1,119 @@
"""Command module for basic-memory project management."""
import os
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager, config
console = Console()
# Create a project subcommand
project_app = typer.Typer(help="Manage multiple Basic Memory projects")
app.add_typer(project_app, name="project")
def format_path(path: str) -> str:
"""Format a path for display, using ~ for home directory."""
home = str(Path.home())
if path.startswith(home):
return path.replace(home, "~", 1)
return path
@project_app.command("list")
def list_projects() -> None:
"""List all configured projects."""
config_manager = ConfigManager()
projects = config_manager.projects
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
table.add_column("Default", style="yellow")
table.add_column("Active", style="magenta")
default_project = config_manager.default_project
active_project = config.project
for name, path in projects.items():
is_default = "" if name == default_project else ""
is_active = "" if name == active_project else ""
table.add_row(name, format_path(path), is_default, is_active)
console.print(table)
@project_app.command("add")
def add_project(
name: str = typer.Argument(..., help="Name of the project"),
path: str = typer.Argument(..., help="Path to the project directory"),
) -> None:
"""Add a new project."""
config_manager = ConfigManager()
try:
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(path))
config_manager.add_project(name, resolved_path)
console.print(f"[green]Project '{name}' added at {format_path(resolved_path)}[/green]")
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
console.print(" # or")
console.print(f" basic-memory project default {name}")
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("remove")
def remove_project(
name: str = typer.Argument(..., help="Name of the project to remove"),
) -> None:
"""Remove a project from configuration."""
config_manager = ConfigManager()
try:
config_manager.remove_project(name)
console.print(f"[green]Project '{name}' removed from configuration[/green]")
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
except ValueError as e: # pragma: no cover
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("default")
def set_default_project(
name: str = typer.Argument(..., help="Name of the project to set as default"),
) -> None:
"""Set the default project."""
config_manager = ConfigManager()
try:
config_manager.set_default_project(name)
console.print(f"[green]Project '{name}' set as default[/green]")
except ValueError as e: # pragma: no cover
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("current")
def show_current_project() -> None:
"""Show the current project."""
config_manager = ConfigManager()
current = os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
try:
path = config_manager.get_project_path(current)
console.print(f"Current project: [cyan]{current}[/cyan]")
console.print(f"Path: [green]{format_path(str(path))}[/green]")
console.print(f"Database: [blue]{format_path(str(config.database_path))}[/blue]")
except ValueError: # pragma: no cover
console.print(f"[yellow]Warning: Project '{current}' not found in configuration[/yellow]")
console.print(f"Using default project: [cyan]{config_manager.default_project}[/cyan]")
+8 -8
View File
@@ -130,15 +130,15 @@ async def run_status(sync_service: SyncService, verbose: bool = False):
@app.command()
@logfire.instrument(extract_args=False)
def status(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
):
"""Show sync status between files and database."""
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
try:
sync_service = asyncio.run(get_sync_service())
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
except Exception as e:
logger.exception(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
raise typer.Exit(code=1) # pragma: no cover
try:
sync_service = asyncio.run(get_sync_service())
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
except Exception as e:
logger.exception(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
raise typer.Exit(code=1) # pragma: no cover
+54 -9
View File
@@ -93,8 +93,10 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V
def display_sync_summary(knowledge: SyncReport):
"""Display a one-line summary of sync changes."""
total_changes = knowledge.total
project_name = config.project
if total_changes == 0:
console.print("[green]Everything up to date[/green]")
console.print(f"[green]Project '{project_name}': Everything up to date[/green]")
return
# Format as: "Synced X files (A new, B modified, C moved, D deleted)"
@@ -113,16 +115,18 @@ def display_sync_summary(knowledge: SyncReport):
if del_count:
changes.append(f"[red]{del_count} deleted[/red]")
console.print(f"Synced {total_changes} files ({', '.join(changes)})")
console.print(f"Project '{project_name}': Synced {total_changes} files ({', '.join(changes)})")
def display_detailed_sync_results(knowledge: SyncReport):
"""Display detailed sync results with trees."""
project_name = config.project
if knowledge.total == 0:
console.print("\n[green]Everything up to date[/green]")
console.print(f"\n[green]Project '{project_name}': Everything up to date[/green]")
return
console.print("\n[bold]Sync Results[/bold]")
console.print(f"\n[bold]Sync Results for Project '{project_name}'[/bold]")
if knowledge.total > 0:
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
@@ -150,23 +154,52 @@ def display_detailed_sync_results(knowledge: SyncReport):
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
"""Run sync operation."""
import time
start_time = time.time()
logger.info(
"Sync command started",
project=config.project,
watch_mode=watch,
verbose=verbose,
directory=str(config.home),
)
sync_service = await get_sync_service()
# Start watching if requested
if watch:
logger.info("Starting watch service after initial sync")
watch_service = WatchService(
sync_service=sync_service,
file_service=sync_service.entity_service.file_service,
config=config,
)
# full sync
await sync_service.sync(config.home)
# full sync - no progress bars in watch mode
await sync_service.sync(config.home, show_progress=False)
# watch changes
await watch_service.run() # pragma: no cover
else:
# one time sync
knowledge_changes = await sync_service.sync(config.home)
# one time sync - use progress bars for better UX
logger.info("Running one-time sync")
knowledge_changes = await sync_service.sync(config.home, show_progress=True)
# Log results
duration_ms = int((time.time() - start_time) * 1000)
logger.info(
"Sync command completed",
project=config.project,
total_changes=knowledge_changes.total,
new_files=len(knowledge_changes.new),
modified_files=len(knowledge_changes.modified),
deleted_files=len(knowledge_changes.deleted),
moved_files=len(knowledge_changes.moves),
duration_ms=duration_ms,
)
# Display results
if verbose:
display_detailed_sync_results(knowledge_changes)
@@ -191,12 +224,24 @@ def sync(
) -> None:
"""Sync knowledge files with the database."""
try:
# Show which project we're syncing
if not watch: # Don't show in watch mode as it would break the UI
typer.echo(f"Syncing project: {config.project}")
typer.echo(f"Project path: {config.home}")
# Run sync
asyncio.run(run_sync(verbose=verbose, watch=watch))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Sync failed", e)
logger.exception(
"Sync command failed",
project=config.project,
error=str(e),
error_type=type(e).__name__,
watch_mode=watch,
directory=str(config.home),
)
typer.echo(f"Error during sync: {e}", err=True)
raise typer.Exit(1)
raise
@@ -1,6 +1,7 @@
"""CLI tool commands for Basic Memory."""
import asyncio
import sys
from typing import Optional, List, Annotated
import typer
@@ -19,24 +20,78 @@ from basic_memory.mcp.prompts.continue_conversation import (
continue_conversation as mcp_continue_conversation,
)
from basic_memory.mcp.prompts.recent_activity import (
recent_activity_prompt as recent_activity_prompt,
)
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import MemoryUrl
from basic_memory.schemas.search import SearchQuery, SearchItemType
tool_app = typer.Typer()
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
app.add_typer(tool_app, name="tool", help="Direct access to MCP tools via CLI")
@tool_app.command()
def write_note(
title: Annotated[str, typer.Option(help="The title of the note")],
content: Annotated[str, typer.Option(help="The content of the note")],
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
content: Annotated[
Optional[str],
typer.Option(
help="The content of the note. If not provided, content will be read from stdin. This allows piping content from other commands, e.g.: cat file.md | basic-memory tools write-note"
),
] = None,
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
):
"""Create or update a markdown note. Content can be provided as an argument or read from stdin.
Content can be provided in two ways:
1. Using the --content parameter
2. Piping content through stdin (if --content is not provided)
Examples:
# Using content parameter
basic-memory tools write-note --title "My Note" --folder "notes" --content "Note content"
# Using stdin pipe
echo "# My Note Content" | basic-memory tools write-note --title "My Note" --folder "notes"
# Using heredoc
cat << EOF | basic-memory tools write-note --title "My Note" --folder "notes"
# My Document
This is my document content.
- Point 1
- Point 2
EOF
# Reading from a file
cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
"""
try:
# If content is not provided, read from stdin
if content is None:
# Check if we're getting data from a pipe or redirect
if not sys.stdin.isatty():
content = sys.stdin.read()
else: # pragma: no cover
# If stdin is a terminal (no pipe/redirect), inform the user
typer.echo(
"No content provided. Please provide content via --content or by piping to stdin.",
err=True,
)
raise typer.Exit(1)
# Also check for empty content
if content is not None and not content.strip():
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
raise typer.Exit(1)
note = asyncio.run(mcp_write_note(title, content, folder, tags))
rprint(note)
except Exception as e: # pragma: no cover
@@ -166,7 +221,7 @@ def continue_conversation(
Optional[str], typer.Option(help="How far back to look for activity")
] = None,
):
"""Continue a previous conversation or work session."""
"""Prompt to continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
@@ -177,3 +232,22 @@ def continue_conversation(
typer.echo(f"Error continuing conversation: {e}", err=True)
raise typer.Exit(1)
raise
# @tool_app.command(name="show-recent-activity")
# def show_recent_activity(
# timeframe: Annotated[
# str, typer.Option(help="How far back to look for activity")
# ] = "7d",
# ):
# """Prompt to show recent activity."""
# try:
# # Prompt functions return formatted strings directly
# session = asyncio.run(recent_activity_prompt(timeframe=timeframe))
# rprint(session)
# except Exception as e: # pragma: no cover
# if not isinstance(e, typer.Exit):
# logger.exception("Error continuing conversation", e)
# typer.echo(f"Error continuing conversation: {e}", err=True)
# raise typer.Exit(1)
# raise
+40 -1
View File
@@ -1,6 +1,7 @@
"""Main CLI entry point for basic-memory.""" # pragma: no cover
from basic_memory.cli.app import app # pragma: no cover
import typer
# Register commands
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
@@ -12,8 +13,46 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
import_claude_conversations,
import_claude_projects,
import_chatgpt,
tools,
tool,
project,
)
# Version command
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
project: str = typer.Option( # noqa
"main",
"--project",
"-p",
help="Specify which project to use",
envvar="BASIC_MEMORY_PROJECT",
),
version: bool = typer.Option(
False,
"--version",
"-V",
help="Show version information and exit.",
is_eager=True,
),
):
"""Basic Memory - Local-first personal knowledge management system."""
if version: # pragma: no cover
from basic_memory import __version__
from basic_memory.config import config
typer.echo(f"Basic Memory v{__version__}")
typer.echo(f"Current project: {config.project}")
typer.echo(f"Project path: {config.home}")
raise typer.Exit()
# Handle project selection via environment variable
if project:
import os
os.environ["BASIC_MEMORY_PROJECT"] = project
if __name__ == "__main__": # pragma: no cover
app()
+155 -7
View File
@@ -1,7 +1,9 @@
"""Configuration management for basic-memory."""
import json
import os
from pathlib import Path
from typing import Literal
from typing import Any, Dict, Literal, Optional
from loguru import logger
from pydantic import Field, field_validator
@@ -12,6 +14,7 @@ from basic_memory.utils import setup_logging
DATABASE_NAME = "memory.db"
DATA_DIR_NAME = ".basic-memory"
CONFIG_FILE_NAME = "config.json"
Environment = Literal["test", "dev", "user"]
@@ -62,15 +65,160 @@ class ProjectConfig(BaseSettings):
return v
# Load project config
config = ProjectConfig()
class BasicMemoryConfig(BaseSettings):
"""Pydantic model for Basic Memory global configuration."""
projects: Dict[str, str] = Field(
default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
description="Mapping of project names to their filesystem paths",
)
default_project: str = Field(
default="main",
description="Name of the default project to use",
)
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
)
def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization."""
# Ensure main project exists
if "main" not in self.projects:
self.projects["main"] = str(Path.home() / "basic-memory")
# Ensure default project is valid
if self.default_project not in self.projects:
self.default_project = "main"
class ConfigManager:
"""Manages Basic Memory configuration."""
def __init__(self) -> None:
"""Initialize the configuration manager."""
self.config_dir = Path.home() / DATA_DIR_NAME
self.config_file = self.config_dir / CONFIG_FILE_NAME
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
# Load or create configuration
self.config = self.load_config()
def load_config(self) -> BasicMemoryConfig:
"""Load configuration from file or create default."""
if self.config_file.exists():
try:
data = json.loads(self.config_file.read_text())
return BasicMemoryConfig(**data)
except Exception as e:
logger.error(f"Failed to load config: {e}")
config = BasicMemoryConfig()
self.save_config(config)
return config
else:
config = BasicMemoryConfig()
self.save_config(config)
return config
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
@property
def projects(self) -> Dict[str, str]:
"""Get all configured projects."""
return self.config.projects.copy()
@property
def default_project(self) -> str:
"""Get the default project name."""
return self.config.default_project
def get_project_path(self, project_name: Optional[str] = None) -> Path:
"""Get the path for a specific project or the default project."""
name = project_name or self.config.default_project
# Check if specified in environment variable
if not project_name and "BASIC_MEMORY_PROJECT" in os.environ:
name = os.environ["BASIC_MEMORY_PROJECT"]
if name not in self.config.projects:
raise ValueError(f"Project '{name}' not found in configuration")
return Path(self.config.projects[name])
def add_project(self, name: str, path: str) -> None:
"""Add a new project to the configuration."""
if name in self.config.projects:
raise ValueError(f"Project '{name}' already exists")
# Ensure the path exists
project_path = Path(path)
project_path.mkdir(parents=True, exist_ok=True)
self.config.projects[name] = str(project_path)
self.save_config(self.config)
def remove_project(self, name: str) -> None:
"""Remove a project from the configuration."""
if name not in self.config.projects:
raise ValueError(f"Project '{name}' not found")
if name == self.config.default_project:
raise ValueError(f"Cannot remove the default project '{name}'")
del self.config.projects[name]
self.save_config(self.config)
def set_default_project(self, name: str) -> None:
"""Set the default project."""
if name not in self.config.projects: # pragma: no cover
raise ValueError(f"Project '{name}' not found")
self.config.default_project = name
self.save_config(self.config)
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
"""Get a project configuration for the specified project."""
config_manager = ConfigManager()
# Get project name from environment variable or use provided name or default
actual_project_name = os.environ.get(
"BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
)
try:
project_path = config_manager.get_project_path(actual_project_name)
return ProjectConfig(home=project_path, project=actual_project_name)
except ValueError: # pragma: no cover
logger.warning(f"Project '{actual_project_name}' not found, using default")
project_path = config_manager.get_project_path(config_manager.default_project)
return ProjectConfig(home=project_path, project=config_manager.default_project)
# Create config manager
config_manager = ConfigManager()
# Load project config for current context
config = get_project_config()
# setup logging to a single log file in user home directory
user_home = Path.home()
log_dir = user_home / DATA_DIR_NAME
log_dir.mkdir(parents=True, exist_ok=True)
# setup logging
setup_logging(
env=config.env,
home_dir=config.home,
home_dir=user_home, # Use user home for logs
log_level=config.log_level,
log_file=".basic-memory/basic-memory.log",
log_file=f"{DATA_DIR_NAME}/basic-memory.log",
console=False,
)
logger.info(f"Starting Basic Memory {basic_memory.__version__}")
logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
+19 -4
View File
@@ -86,8 +86,16 @@ async def get_or_create_db(
_engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
# These checks should never fail since we just created the engine and session maker
# if they were None, but we'll check anyway for the type checker
if _engine is None:
logger.error("Failed to create database engine", db_path=str(db_path))
raise RuntimeError("Database engine initialization failed")
if _session_maker is None:
logger.error("Failed to create session maker", db_path=str(db_path))
raise RuntimeError("Session maker initialization failed")
return _engine, _session_maker
@@ -121,8 +129,15 @@ async def engine_session_factory(
try:
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
# Verify that engine and session maker are initialized
if _engine is None: # pragma: no cover
logger.error("Database engine is None in engine_session_factory")
raise RuntimeError("Database engine initialization failed")
if _session_maker is None: # pragma: no cover
logger.error("Session maker is None in engine_session_factory")
raise RuntimeError("Session maker initialization failed")
yield _engine, _session_maker
finally:
if _engine:
+32 -16
View File
@@ -2,11 +2,13 @@
import hashlib
from pathlib import Path
from typing import Dict, Any, Union
from typing import Any, Dict, Union
import yaml
from loguru import logger
from basic_memory.utils import FilePath
class FileError(Exception):
"""Base exception for file operations."""
@@ -48,42 +50,47 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
raise FileError(f"Failed to compute checksum: {e}")
async def ensure_directory(path: Path) -> None:
async def ensure_directory(path: FilePath) -> None:
"""
Ensure directory exists, creating if necessary.
Args:
path: Directory path to ensure
path: Directory path to ensure (Path or string)
Raises:
FileWriteError: If directory creation fails
"""
try:
path.mkdir(parents=True, exist_ok=True)
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj.mkdir(parents=True, exist_ok=True)
except Exception as e: # pragma: no cover
logger.error(f"Failed to create directory: {path}: {e}")
logger.error("Failed to create directory", path=str(path), error=str(e))
raise FileWriteError(f"Failed to create directory {path}: {e}")
async def write_file_atomic(path: Path, content: str) -> None:
async def write_file_atomic(path: FilePath, content: str) -> None:
"""
Write file with atomic operation using temporary file.
Args:
path: Target file path
path: Target file path (Path or string)
content: Content to write
Raises:
FileWriteError: If write operation fails
"""
temp_path = path.with_suffix(".tmp")
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
temp_path = path_obj.with_suffix(".tmp")
try:
temp_path.write_text(content)
temp_path.replace(path)
logger.debug(f"wrote file: {path}")
temp_path.replace(path_obj)
logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
except Exception as e: # pragma: no cover
temp_path.unlink(missing_ok=True)
logger.error(f"Failed to write file: {path}: {e}")
logger.error("Failed to write file", path=str(path_obj), error=str(e))
raise FileWriteError(f"Failed to write file {path}: {e}")
@@ -173,7 +180,7 @@ def remove_frontmatter(content: str) -> str:
return parts[2].strip()
async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
"""Update frontmatter fields in a file while preserving all content.
Only modifies the frontmatter section, leaving all content untouched.
@@ -181,7 +188,7 @@ async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
Returns checksum of updated file.
Args:
path: Path to markdown file
path: Path to markdown file (Path or string)
updates: Dict of frontmatter fields to update
Returns:
@@ -192,8 +199,11 @@ async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
ParseError: If frontmatter parsing fails
"""
try:
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
# Read current content
content = path.read_text()
content = path_obj.read_text()
# Parse current frontmatter
current_fm = {}
@@ -208,9 +218,15 @@ async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
yaml_fm = yaml.dump(new_fm, sort_keys=False)
final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
await write_file_atomic(path, final_content)
logger.debug("Updating frontmatter", path=str(path_obj), update_keys=list(updates.keys()))
await write_file_atomic(path_obj, final_content)
return await compute_checksum(final_content)
except Exception as e: # pragma: no cover
logger.error(f"Failed to update frontmatter in {path}: {e}")
logger.error(
"Failed to update frontmatter",
path=str(path) if isinstance(path, (str, Path)) else "<unknown>",
error=str(e),
)
raise FileError(f"Failed to update frontmatter: {e}")
+5
View File
@@ -5,6 +5,7 @@ from typing import Optional, Any
from frontmatter import Post
from basic_memory.file_utils import has_frontmatter, remove_frontmatter
from basic_memory.markdown import EntityMarkdown
from basic_memory.models import Entity, Observation as ObservationModel
from basic_memory.utils import generate_permalink
@@ -78,6 +79,10 @@ async def schema_to_markdown(schema: Any) -> Post:
content = schema.content or ""
frontmatter_metadata = dict(schema.entity_metadata or {})
# if the content contains frontmatter, remove it and merge
if has_frontmatter(content):
content = remove_frontmatter(content)
# Remove special fields for ordered frontmatter
for field in ["type", "title", "permalink"]:
frontmatter_metadata.pop(field, None)
-2
View File
@@ -10,12 +10,10 @@ from basic_memory.mcp.prompts import continue_conversation
from basic_memory.mcp.prompts import recent_activity
from basic_memory.mcp.prompts import search
from basic_memory.mcp.prompts import ai_assistant_guide
from basic_memory.mcp.prompts import json_canvas_spec
__all__ = [
"ai_assistant_guide",
"continue_conversation",
"json_canvas_spec",
"recent_activity",
"search",
]
@@ -11,6 +11,7 @@ from basic_memory.mcp.server import mcp
name="ai assistant guide",
description="Give an AI assistant guidance on how to use Basic Memory tools effectively",
)
@logfire.instrument(extract_args=False)
def ai_assistant_guide() -> str:
"""Return a concise guide on Basic Memory tools and how to use them.
@@ -20,9 +21,8 @@ def ai_assistant_guide() -> str:
Returns:
A focused guide on Basic Memory usage.
"""
with logfire.span("Getting Basic Memory guide"): # pyright: ignore
logger.info("Loading AI assistant guide resource")
guide_doc = Path(__file__).parent.parent.parent.parent.parent / "data/ai_assistant_guide.md"
content = guide_doc.read_text()
logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
return content
logger.info("Loading AI assistant guide resource")
guide_doc = Path(__file__).parent.parent.parent.parent.parent / "data/ai_assistant_guide.md"
content = guide_doc.read_text()
logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
return content
@@ -5,12 +5,13 @@ providing context from previous interactions to maintain continuity.
"""
from textwrap import dedent
from typing import Optional, List, Annotated
from typing import Optional, Annotated
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.build_context import build_context
from basic_memory.mcp.tools.recent_activity import recent_activity
@@ -21,9 +22,10 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
@mcp.prompt(
name="continue conversation",
name="Continue Conversation",
description="Continue a previous conversation",
)
@logfire.instrument(extract_args=False)
async def continue_conversation(
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
timeframe: Annotated[
@@ -43,140 +45,69 @@ async def continue_conversation(
Returns:
Context from previous sessions on this topic
"""
with logfire.span("Continuing session", topic=topic, timeframe=timeframe): # pyright: ignore
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
# If topic provided, search for it
if topic:
search_results = await search(
SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY])
)
# If topic provided, search for it
if topic:
search_results = await search(
SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY])
)
# Build context from results
contexts = []
for result in search_results.results:
if hasattr(result, "permalink") and result.permalink:
context = await build_context(f"memory://{result.permalink}")
contexts.append(context)
# Build context from results
contexts = []
for result in search_results.results:
if hasattr(result, "permalink") and result.permalink:
context: GraphContext = await build_context(f"memory://{result.permalink}")
if context.primary_results:
contexts.append(
PromptContextItem(
primary_results=context.primary_results[:1], # pyright: ignore
related_results=context.related_results[:3], # pyright: ignore
)
)
# get context for the top 3 results
return format_continuation_context(topic, contexts[:3], timeframe)
# get context for the top 3 results
prompt_context = format_prompt_context(
PromptContext(topic=topic, timeframe=timeframe, results=contexts) # pyright: ignore
)
else:
# If no topic, get recent activity
recent = await recent_activity(timeframe=timeframe)
return format_continuation_context("Recent Activity", [recent], timeframe)
timeframe = timeframe or "7d"
recent: GraphContext = await recent_activity(
timeframe=timeframe, type=[SearchItemType.ENTITY]
)
prompt_context = format_prompt_context(
PromptContext(
topic=f"Recent Activity from ({timeframe})",
timeframe=timeframe,
results=[
PromptContextItem(
primary_results=recent.primary_results[:5], # pyright: ignore
related_results=recent.related_results[:2], # pyright: ignore
)
],
)
)
def format_continuation_context(
topic: str, contexts: List[GraphContext], timeframe: TimeFrame | None
) -> str:
"""Format continuation context into a helpful summary.
Args:
topic: The topic or focus of continuation
contexts: List of context graphs
timeframe: How far back to look for activity
Returns:
Formatted continuation summary
"""
if not contexts or all(not context.primary_results for context in contexts):
return dedent(f"""
# Continuing conversation on: {topic}
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
I couldn't find any recent work specifically on this topic.
## Suggestions
- Try a different search term
- Check recent activity with `recent_activity(timeframe="1w")`
- Start a new topic with `write_note(...)`
""")
# Start building our summary with header
summary = dedent(f"""
# Continuing conversation on: {topic}
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
Here's what I found about the previous conversation:
""")
# Track what we've added to avoid duplicates
added_permalinks = set()
sections = []
# Process each context
for context in contexts:
# Add primary results
for primary in context.primary_results:
if hasattr(primary, "permalink") and primary.permalink not in added_permalinks:
added_permalinks.add(primary.permalink)
section = dedent(f"""
## {primary.title}
- **Type**: {primary.type}
""")
# Add creation date if available
if hasattr(primary, "created_at"):
section += f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}\n"
# Add content snippet
if hasattr(primary, "content") and primary.content: # pyright: ignore
content = primary.content or "" # pyright: ignore
if content:
section += f"- **Content Snippet**: {content}\n"
section += dedent(f"""
You can read this document with: `read_note("{primary.permalink}")`
""")
# Add related documents if available
related_by_type = {}
if context.related_results:
for related in context.related_results:
if hasattr(related, "relation_type") and related.relation_type: # pyright: ignore
if related.relation_type not in related_by_type: # pyright: ignore
related_by_type[related.relation_type] = [] # pyright: ignore
related_by_type[related.relation_type].append(related) # pyright: ignore
if related_by_type:
section += dedent("""
### Related Documents
""")
for rel_type, relations in related_by_type.items():
display_type = rel_type.replace("_", " ").title()
section += f"- **{display_type}**:\n"
for rel in relations[:3]: # Limit to avoid overwhelming
if hasattr(rel, "to_entity") and rel.to_entity:
section += f" - `{rel.to_entity}`\n"
sections.append(section)
# Add all sections
summary += "\n".join(sections)
# Add next steps
# Add next steps with strong encouragement to write
next_steps = dedent(f"""
## Next Steps
You can:
- Explore more with: `search({{"text": "{topic}"}})`
- See what's changed: `recent_activity(timeframe="{timeframe}")`
- See what's changed: `recent_activity(timeframe="{timeframe or "7d"}")`
- **Record new learnings or decisions from this conversation:** `write_note(title="[Create a meaningful title]", content="[Content with observations and relations]")`
## Knowledge Capture Recommendation
As you continue this conversation, **actively look for opportunities to:**
1. Record key information, decisions, or insights that emerge
2. Link new knowledge to existing topics
3. Suggest capturing important context when appropriate
4. Create forward references to topics that might be created later
Remember that capturing knowledge during conversations is one of the most valuable aspects of Basic Memory.
""")
# Add specific exploration based on what we found
if added_permalinks:
first_permalink = next(iter(added_permalinks))
next_steps += dedent(f"""
- Continue the conversation: `build_context("memory://{first_permalink}")`
""")
return summary + next_steps
return prompt_context + next_steps
@@ -1,27 +0,0 @@
from pathlib import Path
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@mcp.resource(
uri="memory://json_canvas_spec",
name="json canvas spec",
description="JSON Canvas specification for visualizing knowledge graphs in Obsidian",
)
def json_canvas_spec() -> str:
"""Return the JSON Canvas specification for Obsidian visualizations.
Returns:
The JSON Canvas specification document.
"""
with logfire.span("Getting JSON Canvas spec"): # pyright: ignore
logger.info("Loading JSON Canvas spec resource")
canvas_spec = (
Path(__file__).parent.parent.parent.parent.parent / "data/json_canvas_spec_1_0.md"
)
content = canvas_spec.read_text()
logger.info(f"Loaded JSON Canvas spec ({len(content)} chars)")
return content
+56 -12
View File
@@ -3,27 +3,29 @@
These prompts help users see what has changed in their knowledge base recently.
"""
from typing import Annotated, Optional
from typing import Annotated
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_context_summary
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.recent_activity import recent_activity as recent_activity_tool
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.search import SearchItemType
@mcp.prompt(
name="recent activity",
name="Share Recent Activity",
description="Get recent activity from across the knowledge base",
)
@logfire.instrument(extract_args=False)
async def recent_activity_prompt(
timeframe: Annotated[
Optional[TimeFrame],
TimeFrame,
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
] = "7d",
) -> str:
"""Get recent activity from across the knowledge base.
@@ -36,11 +38,53 @@ async def recent_activity_prompt(
Returns:
Formatted summary of recent activity
"""
with logfire.span("Getting recent activity", timeframe=timeframe): # pyright: ignore
logger.info(f"Getting recent activity, timeframe: {timeframe}")
logger.info(f"Getting recent activity, timeframe: {timeframe}")
results = await recent_activity_tool(timeframe=timeframe)
recent = await recent_activity(timeframe=timeframe, type=[SearchItemType.ENTITY])
time_display = f" ({timeframe})" if timeframe else ""
header = f"# Recent Activity{time_display}"
return format_context_summary(header, results)
prompt_context = format_prompt_context(
PromptContext(
topic=f"Recent Activity from ({timeframe})",
timeframe=timeframe,
results=[
PromptContextItem(
primary_results=recent.primary_results[:5],
related_results=recent.related_results[:2],
)
],
)
)
# Add suggestions for summarizing recent activity
capture_suggestions = f"""
## Opportunity to Capture Activity Summary
Consider creating a summary note of recent activity:
```python
await write_note(
title="Activity Summary {timeframe}",
content='''
# Activity Summary for {timeframe}
## Overview
[Summary of key changes and developments over this period]
## Key Updates
[List main updates and their significance]
## Observations
- [trend] [Observation about patterns in recent activity]
- [insight] [Connection between different activities]
## Relations
- summarizes [[{recent.primary_results[0].title if recent.primary_results else "Recent Topic"}]]
- relates_to [[Project Overview]]
'''
)
```
Summarizing periodic activity helps create high-level insights and connections between topics.
"""
return prompt_context + capture_suggestions
+72 -15
View File
@@ -17,9 +17,10 @@ from basic_memory.schemas.base import TimeFrame
@mcp.prompt(
name="search",
name="Search Knowledge Base",
description="Search across all content in basic-memory",
)
@logfire.instrument(extract_args=False)
async def search_prompt(
query: str,
timeframe: Annotated[
@@ -39,11 +40,10 @@ async def search_prompt(
Returns:
Formatted search results with context
"""
with logfire.span("Searching knowledge base", query=query, timeframe=timeframe): # pyright: ignore
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
return format_search_results(query, search_results, timeframe)
search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
return format_search_results(query, search_results, timeframe)
def format_search_results(
@@ -65,11 +65,33 @@ def format_search_results(
I couldn't find any results for this query.
## Suggestions
## Opportunity to Capture Knowledge!
This is an excellent opportunity to create new knowledge on this topic. Consider:
```python
await write_note(
title="{query.capitalize()}",
content=f'''
# {query.capitalize()}
## Overview
[Summary of what we've discussed about {query}]
## Observations
- [category] [First observation about {query}]
- [category] [Second observation about {query}]
## Relations
- relates_to [[Other Relevant Topic]]
'''
)
```
## Other Suggestions
- Try a different search term
- Broaden your search criteria
- Check recent activity with `recent_activity(timeframe="1w")`
- Create new content with `write_note(...)`
""")
# Start building our summary with header
@@ -88,32 +110,38 @@ def format_search_results(
for i, result in enumerate(results.results[:5]): # Limit to top 5 results
summary += dedent(f"""
## {i + 1}. {result.title}
- **Type**: {result.type}
- **Type**: {result.type.value}
""")
# Add creation date if available in metadata
if hasattr(result, "metadata") and result.metadata and "created_at" in result.metadata:
if result.metadata and "created_at" in result.metadata:
created_at = result.metadata["created_at"]
if hasattr(created_at, "strftime"):
summary += f"- **Created**: {created_at.strftime('%Y-%m-%d %H:%M')}\n"
summary += (
f"- **Created**: {created_at.strftime('%Y-%m-%d %H:%M')}\n" # pragma: no cover
)
elif isinstance(created_at, str):
summary += f"- **Created**: {created_at}\n"
# Add score and excerpt
summary += f"- **Relevance Score**: {result.score:.2f}\n"
# Add excerpt if available in metadata
if hasattr(result, "metadata") and result.metadata and "excerpt" in result.metadata:
summary += f"- **Excerpt**: {result.metadata['excerpt']}\n"
if result.content:
summary += f"- **Excerpt**:\n{result.content}\n"
# Add permalink for retrieving content
if hasattr(result, "permalink") and result.permalink:
if result.permalink:
summary += dedent(f"""
You can view this content with: `read_note("{result.permalink}")`
Or explore its context with: `build_context("memory://{result.permalink}")`
""")
else:
summary += dedent(f"""
You can view this file with: `read_file("{result.file_path}")`
""") # pragma: no cover
# Add next steps
# Add next steps with strong write encouragement
summary += dedent(f"""
## Next Steps
@@ -122,6 +150,35 @@ def format_search_results(
- Exclude terms: `search("{query} NOT exclude_term")`
- View more results: `search("{query}", after_date=None)`
- Check recent activity: `recent_activity()`
## Synthesize and Capture Knowledge
Consider creating a new note that synthesizes what you've learned:
```python
await write_note(
title="Synthesis of {query.capitalize()} Information",
content='''
# Synthesis of {query.capitalize()} Information
## Overview
[Synthesis of the search results and your conversation]
## Key Insights
[Summary of main points learned from these results]
## Observations
- [insight] [Important observation from search results]
- [connection] [How this connects to other topics]
## Relations
- relates_to [[{results.results[0].title if results.results else "Related Topic"}]]
- extends [[Another Relevant Topic]]
'''
)
```
Remember that capturing synthesized knowledge is one of the most valuable features of Basic Memory.
""")
return summary
+132 -75
View File
@@ -4,95 +4,152 @@ These utilities help format data from various tools into consistent,
user-friendly markdown summaries.
"""
from basic_memory.schemas.memory import GraphContext
from dataclasses import dataclass
from textwrap import dedent
from typing import List
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
normalize_memory_url,
EntitySummary,
RelationSummary,
ObservationSummary,
)
def format_context_summary(header: str, context: GraphContext) -> str:
"""Format GraphContext as a helpful markdown summary.
@dataclass
class PromptContextItem:
primary_results: List[EntitySummary]
related_results: List[EntitySummary | RelationSummary | ObservationSummary]
This creates a user-friendly markdown response that explains the context
and provides guidance on how to explore further.
Args:
header: The title to use for the summary
context: The GraphContext object to format
@dataclass
class PromptContext:
timeframe: TimeFrame
topic: str
results: List[PromptContextItem]
def format_prompt_context(context: PromptContext) -> str:
"""Format continuation context into a helpful summary.
Returns:
Formatted markdown string with the context summary
Formatted continuation summary
"""
summary = []
if not context.results:
return dedent(f"""
# Continuing conversation on: {context.topic}
# Extract URI for reference
uri = context.metadata.uri or "a/permalink-value"
# Add header
summary.append(f"{header}")
summary.append("")
# Primary document section
if context.primary_results:
summary.append(f"## Primary Documents ({len(context.primary_results)})")
for primary in context.primary_results:
summary.append(f"### {primary.title}")
summary.append(f"- **Type**: {primary.type}")
summary.append(f"- **Path**: {primary.file_path}")
summary.append(f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}")
summary.append("")
summary.append(
f'To view this document\'s content: `read_note("{primary.permalink}")` or `read_note("{primary.title}")` '
This is a memory retrieval session.
The supplied query did not return any information specifically on this topic.
## Opportunity to Capture New Knowledge!
This is an excellent chance to start documenting this topic:
```python
await write_note(
title="{context.topic}",
content=f'''
# {context.topic}
## Overview
[Summary of what we know about {context.topic}]
## Key Points
[Main aspects or components of {context.topic}]
## Observations
- [category] [First important observation about {context.topic}]
- [category] [Second observation about {context.topic}]
## Relations
- relates_to [[Related Topic]]
- part_of [[Broader Context]]
'''
)
summary.append("")
else:
summary.append("\nNo primary documents found.")
```
## Other Options
Please use the available basic-memory tools to gather relevant context before responding.
You can also:
- Try a different search term
- Check recent activity with `recent_activity(timeframe="1w")`
""")
# Related documents section
if context.related_results:
summary.append(f"## Related Documents ({len(context.related_results)})")
# Start building our summary with header - add knowledge capture emphasis
summary = dedent(f"""
# Continuing conversation on: {context.topic}
# Group by relation type for better organization
relation_types = {}
for rel in context.related_results:
if hasattr(rel, "relation_type"):
rel_type = rel.relation_type # pyright: ignore
if rel_type not in relation_types:
relation_types[rel_type] = []
relation_types[rel_type].append(rel)
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
# Display relations grouped by type
for rel_type, relations in relation_types.items():
summary.append(f"### {rel_type.replace('_', ' ').title()} ({len(relations)})")
Here's what I found from previous conversations:
> **Knowledge Capture Recommendation:** As you continue this conversation, actively look for opportunities to record new information, decisions, or insights that emerge. Use `write_note()` to document important context.
""")
for rel in relations:
if hasattr(rel, "to_id") and rel.to_id:
summary.append(f"- **{rel.to_id}**")
summary.append(f' - View document: `read_note("{rel.to_id}")` ')
summary.append(
f' - Explore connections: `build_context("memory://{rel.to_id}")` '
# Track what we've added to avoid duplicates
added_permalinks = set()
sections = []
# Process each context
for context in context.results: # pyright: ignore
for primary in context.primary_results: # pyright: ignore
if primary.permalink not in added_permalinks:
primary_permalink = primary.permalink
added_permalinks.add(primary_permalink)
memory_url = normalize_memory_url(primary_permalink)
section = dedent(f"""
--- {memory_url}
## {primary.title}
- **Type**: {primary.type}
""")
# Add creation date
section += f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}\n"
# Add content snippet
if hasattr(primary, "content") and primary.content: # pyright: ignore
content = primary.content or "" # pyright: ignore
if content:
section += f"\n**Excerpt**:\n{content}\n"
section += dedent(f"""
You can read this document with: `read_note("{primary_permalink}")`
""")
sections.append(section)
if context.related_results: # pyright: ignore
section += dedent( # pyright: ignore
"""
## Related Context
"""
)
for related in context.related_results: # pyright: ignore
section_content = dedent(f"""
- type: **{related.type}**
- title: {related.title}
""")
if related.permalink:
section_content += (
f'You can view this document with: `read_note("{related.permalink}")`'
)
else:
summary.append(f"- **Unresolved relation**: {rel.permalink}")
summary.append("")
section_content += (
f'You can view this file with: `read_file("{related.file_path}")`'
)
# Next steps section
summary.append("## Next Steps")
summary.append("Here are some ways to explore further:")
section += section_content
sections.append(section)
search_term = uri.split("/")[-1]
summary.append(f'- **Search related topics**: `search({{"text": "{search_term}"}})`')
summary.append('- **Check recent changes**: `recent_activity(timeframe="3 days")`')
summary.append(f'- **Explore all relations**: `build_context("memory://{uri}/*")`')
# Tips section
summary.append("")
summary.append("## Tips")
summary.append(
f'- For more specific context, increase depth: `build_context("memory://{uri}", depth=2)`'
)
summary.append(
"- You can follow specific relation types using patterns like: `memory://document/relation-type/*`"
)
summary.append("- Look for connected documents by checking relations between them")
return "\n".join(summary)
# Add all sections
summary += "\n".join(sections)
return summary
+15 -15
View File
@@ -17,6 +17,7 @@ from basic_memory.schemas.memory import (
from basic_memory.schemas.base import TimeFrame
@logfire.instrument(extract_args=False)
@mcp.tool(
description="""Build context from a memory:// URI to continue conversations naturally.
@@ -70,18 +71,17 @@ async def build_context(
# Research the history of a feature
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
"""
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
+18 -18
View File
@@ -17,6 +17,7 @@ from basic_memory.mcp.tools.utils import call_put
@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
)
@logfire.instrument(extract_args=False)
async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
@@ -73,27 +74,26 @@ async def canvas(
}
```
"""
with logfire.span("Creating canvas", folder=folder, title=title): # type: ignore
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path}")
response = await call_put(client, f"/resource/{file_path}", json=canvas_json)
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path}")
response = await call_put(client, f"/resource/{file_path}", json=canvas_json)
# Parse response
result = response.json()
logger.debug(result)
# Parse response
result = response.json()
logger.debug(result)
# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
return "\n".join(summary)
return "\n".join(summary)
+4 -4
View File
@@ -9,6 +9,7 @@ from basic_memory.schemas import DeleteEntitiesResponse
@mcp.tool(description="Delete a note by title or permalink")
@logfire.instrument(extract_args=False)
async def delete_note(identifier: str) -> bool:
"""Delete a note from the knowledge base.
@@ -25,7 +26,6 @@ async def delete_note(identifier: str) -> bool:
# Delete by permalink
delete_note("notes/project-planning")
"""
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
+149 -17
View File
@@ -1,36 +1,37 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
import logfire
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import memory_url_path
from basic_memory.schemas.search import SearchQuery
@mcp.tool(
description="Read a markdown note by title or permalink.",
)
@logfire.instrument(extract_args=False)
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
"""Read a markdown note from the knowledge base.
This tool finds and retrieves a note by its title or permalink, returning
the raw markdown content including observations, relations, and metadata.
Unlike read_file, this tool is aware of the knowledge graph structure and
will attempt to resolve entity references if the file path doesn't exist.
This tool finds and retrieves a note by its title, permalink, or content search,
returning the raw markdown content including observations, relations, and metadata.
It will try multiple lookup strategies to find the most relevant note.
Args:
identifier: The title or permalink of the note to read
Can be a full memory:// URL, a permalink, or a title
Can be a full memory:// URL, a permalink, a title, or search text
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
Returns:
The full markdown content of the note, either from file content
or constructed from entity data if direct file access fails.
For entities without markdown content, returns a message indicating
the entity was found but has no content.
The full markdown content of the note if found, or helpful guidance if not found.
Examples:
# Read by permalink
@@ -45,16 +46,147 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Read with pagination
read_note("Project Updates", page=2, page_size=5)
"""
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
# Get the file via REST API
entity_path = memory_url_path(identifier)
path = f"/resource/{entity_path}"
logger.info(f"Reading note from URL: {path}")
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
path = f"/resource/{entity_path}"
logger.info(f"Attempting to read note from URL: {path}")
try:
# Try direct lookup first
response = await call_get(client, path, params={"page": page, "page_size": page_size})
# Just return the content as a string
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
return response.text
else:
return f"Error: Could not find entity at {identifier}"
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{path}': {e}")
# Continue to fallback methods
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search(SearchQuery(title=identifier))
if title_results and title_results.results:
result = title_results.results[0] # Get the first/best match
if result.permalink:
try:
# Try to fetch the content using the found permalink
path = f"/resource/{result.permalink}"
response = await call_get(
client, path, params={"page": page, "page_size": page_size}
)
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {result.permalink}: {e}"
)
else:
logger.info(f"No results in title search for: {identifier}")
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search(SearchQuery(text=identifier))
# We didn't find a direct match, construct a helpful error message
if not text_results or not text_results.results:
# No results at all
return format_not_found_message(identifier)
else:
# We found some related results
return format_related_results(identifier, text_results.results[:5])
def format_not_found_message(identifier: str) -> str:
"""Format a helpful message when no note was found."""
return dedent(f"""
# Note Not Found: "{identifier}"
I couldn't find any notes matching "{identifier}". Here are some suggestions:
## Check Identifier Type
- If you provided a title, try using the exact permalink instead
- If you provided a permalink, check for typos or try a broader search
## Search Instead
Try searching for related content:
```
search(query="{identifier}")
```
## Recent Activity
Check recently modified notes:
```
recent_activity(timeframe="7d")
```
## Create New Note
This might be a good opportunity to create a new note on this topic:
```
write_note(
title="{identifier.capitalize()}",
content='''
# {identifier.capitalize()}
## Overview
[Your content here]
## Observations
- [category] [Observation about {identifier}]
## Relations
- relates_to [[Related Topic]]
''',
folder="notes"
)
```
""")
def format_related_results(identifier: str, results) -> str:
"""Format a helpful message with related results when an exact match wasn't found."""
message = dedent(f"""
# Note Not Found: "{identifier}"
I couldn't find an exact match for "{identifier}", but I found some related notes:
""")
for i, result in enumerate(results):
message += dedent(f"""
## {i + 1}. {result.title}
- **Type**: {result.type.value}
- **Permalink**: {result.permalink}
You can read this note with:
```
read_note("{result.permalink}")
```
""")
message += dedent("""
## Try More Specific Lookup
For exact matches, try using the full permalink from one of the results above.
## Search For More Results
To see more related content:
```
search(query="{identifier}")
```
## Create New Note
If none of these match what you're looking for, consider creating a new note:
```
write_note(
title="[Your title]",
content="[Your content]",
folder="notes"
)
```
""")
return message
+24 -24
View File
@@ -25,6 +25,7 @@ from basic_memory.schemas.search import SearchItemType
Or standard formats like "7d"
""",
)
@logfire.instrument(extract_args=False)
async def recent_activity(
type: Optional[List[SearchItemType]] = None,
depth: Optional[int] = 1,
@@ -74,29 +75,28 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
)
params = {
"page": page,
"page_size": page_size,
"max_related": max_related,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
logger.info(
f"Getting recent activity from type={type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
)
params = {
"page": page,
"page_size": page_size,
"max_related": max_related,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
# send enum values if we have an enum, else send string value
if type:
params["type"] = [ # pyright: ignore
type.value if isinstance(type, SearchItemType) else type for type in type
]
# send enum values if we have an enum, else send string value
if type:
params["type"] = [ # pyright: ignore
type.value if isinstance(type, SearchItemType) else type for type in type
]
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
+9 -9
View File
@@ -9,6 +9,7 @@ from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.mcp.async_client import client
@logfire.instrument(extract_args=False)
@mcp.tool(
description="Search across all content in basic-memory, including documents and entities",
)
@@ -65,12 +66,11 @@ async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> Sear
permalink_match="docs/meeting-*"
))
"""
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Searching for {query}")
response = await call_post(
client,
"/search/",
json=query.model_dump(),
params={"page": page, "page_size": page_size},
)
return SearchResponse.model_validate(response.json())
logger.info(f"Searching for {query}")
response = await call_post(
client,
"/search/",
json=query.model_dump(),
params={"page": page, "page_size": page_size},
)
return SearchResponse.model_validate(response.json())
+58 -41
View File
@@ -15,6 +15,7 @@ from basic_memory.mcp.tools.utils import call_put
@mcp.tool(
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
)
@logfire.instrument(extract_args=False)
async def write_note(
title: str,
content: str,
@@ -57,53 +58,69 @@ async def write_note(
- Relation counts (resolved/unresolved)
- Tags if present
"""
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# 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())
# Create or update via knowledge API
logger.debug("Creating entity via API", permalink=entity.permalink)
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
summary = [
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
f"permalink: {result.permalink}",
]
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
summary = [
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
f"permalink: {result.permalink}",
]
if result.observations:
categories = {}
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
# Count observations by category
categories = {}
if result.observations:
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}")
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
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
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.")
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)}")
if tags:
summary.append(f"\n## Tags\n- {', '.join(tags)}")
return "\n".join(summary)
# Log the response with structured data
logger.info(
"MCP tool response",
tool="write_note",
action=action,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
resolved_relations=resolved,
unresolved_relations=unresolved,
status_code=response.status_code,
)
return "\n".join(summary)
+18 -2
View File
@@ -70,7 +70,15 @@ class Repository[T: Base]:
# Query within same session
found = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
assert found is not None, "can't find model after session.add"
if found is None: # pragma: no cover
logger.error(
"Failed to retrieve model after add",
model_type=self.Model.__name__,
model_id=model.id, # pyright: ignore
)
raise ValueError(
f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
)
return found
async def add_all(self, models: List[T]) -> Sequence[T]:
@@ -152,7 +160,15 @@ class Repository[T: Base]:
await session.flush()
return_instance = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
assert return_instance is not None, "can't find model after session.add"
if return_instance is None: # pragma: no cover
logger.error(
"Failed to retrieve model after create",
model_type=self.Model.__name__,
model_id=model.id, # pyright: ignore
)
raise ValueError(
f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
)
return return_instance
async def create_all(self, data_list: List[dict]) -> Sequence[T]:
@@ -206,7 +206,7 @@ class SearchRepository:
OFFSET :offset
"""
logger.debug(f"Search {sql} params: {params}")
logger.trace(f"Search {sql} params: {params}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
+1
View File
@@ -59,6 +59,7 @@ class SearchQuery(BaseModel):
return (
self.permalink is None
and self.permalink_match is None
and self.title is None
and self.text is None
and self.after_date is None
and self.types is None
+8 -2
View File
@@ -144,7 +144,7 @@ class EntityService(BaseService[EntityModel]):
post = await schema_to_markdown(schema)
# write file
final_content = frontmatter.dumps(post)
final_content = frontmatter.dumps(post, sort_keys=False)
checksum = await self.file_service.write_file(file_path, final_content)
# parse entity from file
@@ -171,7 +171,13 @@ class EntityService(BaseService[EntityModel]):
entity = await self.get_by_permalink(permalink_or_id)
else:
entities = await self.get_entities_by_id([permalink_or_id])
assert len(entities) == 1, f"Expected 1 entity, got {len(entities)}"
if len(entities) != 1: # pragma: no cover
logger.error(
"Entity lookup error", entity_id=permalink_or_id, found_count=len(entities)
)
raise ValueError(
f"Expected 1 entity with ID {permalink_or_id}, got {len(entities)}"
)
entity = entities[0]
# Delete file first
+105 -53
View File
@@ -3,7 +3,7 @@
import mimetypes
from os import stat_result
from pathlib import Path
from typing import Tuple, Union, Dict, Any
from typing import Any, Dict, Tuple, Union
from loguru import logger
@@ -13,6 +13,7 @@ from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Entity as EntityModel
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.services.exceptions import FileOperationError
from basic_memory.utils import FilePath
class FileService:
@@ -60,7 +61,7 @@ class FileService:
Returns:
Raw content string without metadata sections
"""
logger.debug(f"Reading entity with permalink: {entity.permalink}")
logger.debug("Reading entity content", entity_id=entity.id, permalink=entity.permalink)
file_path = self.get_entity_path(entity)
markdown = await self.markdown_processor.read_file(file_path)
@@ -78,13 +79,13 @@ class FileService:
path = self.get_entity_path(entity)
await self.delete_file(path)
async def exists(self, path: Union[Path, str]) -> bool:
async def exists(self, path: FilePath) -> bool:
"""Check if file exists at the provided path.
If path is relative, it is assumed to be relative to base_path.
Args:
path: Path to check (Path object or string)
path: Path to check (Path or string)
Returns:
True if file exists, False otherwise
@@ -93,23 +94,25 @@ class FileService:
FileOperationError: If check fails
"""
try:
path = Path(path)
if path.is_absolute():
return path.exists()
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
if path_obj.is_absolute():
return path_obj.exists()
else:
return (self.base_path / path).exists()
return (self.base_path / path_obj).exists()
except Exception as e:
logger.error(f"Failed to check file existence {path}: {e}")
logger.error("Failed to check file existence", path=str(path), error=str(e))
raise FileOperationError(f"Failed to check file existence: {e}")
async def write_file(self, path: Union[Path, str], content: str) -> str:
async def write_file(self, path: FilePath, content: str) -> str:
"""Write content to file and return checksum.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Where to write (Path object or string)
path: Where to write (Path or string)
content: Content to write
Returns:
@@ -118,34 +121,43 @@ class FileService:
Raises:
FileOperationError: If write fails
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
# Ensure parent directory exists
await file_utils.ensure_directory(full_path.parent)
# Write content atomically
logger.info(
"Writing file",
operation="write_file",
path=str(full_path),
content_length=len(content),
is_markdown=full_path.suffix.lower() == ".md",
)
await file_utils.write_file_atomic(full_path, content)
# Compute and return checksum
checksum = await file_utils.compute_checksum(content)
logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
logger.debug("File write completed", path=str(full_path), checksum=checksum)
return checksum
except Exception as e:
logger.error(f"Failed to write file {full_path}: {e}")
logger.exception("File write error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to write file: {e}")
# TODO remove read_file
async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
async def read_file(self, path: FilePath) -> Tuple[str, str]:
"""Read file and compute checksum.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path to read (Path object or string)
path: Path to read (Path or string)
Returns:
Tuple of (content, checksum)
@@ -153,45 +165,74 @@ class FileService:
Raises:
FileOperationError: If read fails
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
logger.debug("Reading file", operation="read_file", path=str(full_path))
content = full_path.read_text()
checksum = await file_utils.compute_checksum(content)
logger.debug(f"read file: {full_path}, checksum: {checksum}")
logger.debug(
"File read completed",
path=str(full_path),
checksum=checksum,
content_length=len(content),
)
return content, checksum
except Exception as e:
logger.error(f"Failed to read file {full_path}: {e}")
logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}")
async def delete_file(self, path: Union[Path, str]) -> None:
async def delete_file(self, path: FilePath) -> None:
"""Delete file if it exists.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path to delete (Path object or string)
path: Path to delete (Path or string)
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
full_path.unlink(missing_ok=True)
async def update_frontmatter(self, path: Union[Path, str], updates: Dict[str, Any]) -> str:
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
"""
Update frontmatter fields in a file while preserving all content.
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
Args:
path: Path to the file (Path or string)
updates: Dictionary of frontmatter fields to update
Returns:
Checksum of updated file
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
return await file_utils.update_frontmatter(full_path, updates)
async def compute_checksum(self, path: Union[str, Path]) -> str:
"""Compute checksum for a file."""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
async def compute_checksum(self, path: FilePath) -> str:
"""Compute checksum for a file.
Args:
path: Path to the file (Path or string)
Returns:
Checksum of the file content
Raises:
FileError: If checksum computation fails
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
if self.is_markdown(path):
# read str
@@ -202,28 +243,36 @@ class FileService:
return await file_utils.compute_checksum(content)
except Exception as e: # pragma: no cover
logger.error(f"Failed to compute checksum for {path}: {e}")
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: Union[Path, str]) -> stat_result:
def file_stats(self, path: FilePath) -> stat_result:
"""Return file stats for a given path.
Args:
path: Path to the file (Path or string)
Returns:
File statistics
"""
Return file stats for a given path.
:param path:
:return:
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
return full_path.stat()
def content_type(self, path: Union[Path, str]) -> str:
def content_type(self, path: FilePath) -> str:
"""Return content_type for a given path.
Args:
path: Path to the file (Path or string)
Returns:
MIME type of the file
"""
Return content_type for a given path.
:param path:
:return:
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
mime_type, _ = mimetypes.guess_type(full_path.name)
@@ -234,10 +283,13 @@ class FileService:
content_type = mime_type or "text/plain"
return content_type
def is_markdown(self, path: Union[Path, str]) -> bool:
"""
Return content_type for a given path.
:param path:
:return:
def is_markdown(self, path: FilePath) -> bool:
"""Check if a file is a markdown file.
Args:
path: Path to the file (Path or string)
Returns:
True if the file is a markdown file, False otherwise
"""
return self.content_type(path) == "text/markdown"
+20 -6
View File
@@ -179,9 +179,16 @@ class SearchService:
Each type gets its own row in the search index with appropriate metadata.
"""
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
if entity.permalink is None: # pragma: no cover
logger.error(
"Missing permalink for markdown entity",
entity_id=entity.id,
title=entity.title,
file_path=entity.file_path,
)
raise ValueError(
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
)
content_stems = []
content_snippet = ""
@@ -198,9 +205,16 @@ class SearchService:
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
if entity.permalink is None: # pragma: no cover
logger.error(
"Missing permalink for markdown entity",
entity_id=entity.id,
title=entity.title,
file_path=entity.file_path,
)
raise ValueError(
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
)
# Index entity
await self.repository.index_item(
+270 -32
View File
@@ -1,12 +1,15 @@
"""Service for syncing files between filesystem and database."""
# Suppress logfire warnings
import os
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from pathlib import Path
from typing import Set, Dict
from typing import Tuple
from typing import Dict, Optional, Set, Tuple
import logfire
from loguru import logger
@@ -78,22 +81,126 @@ class SyncService:
self.search_service = search_service
self.file_service = file_service
async def sync(self, directory: Path) -> SyncReport:
@logfire.instrument(extract_args=False)
async def sync(self, directory: Path, show_progress: bool = True) -> SyncReport:
"""Sync all files with database."""
import time
from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn
with logfire.span(f"sync {directory}", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
# initial paths from db to sync
# path -> checksum
report = await self.scan(directory)
start_time = time.time()
console = None
progress = None # Will be initialized if show_progress is True
# order of sync matters to resolve relations effectively
logger.info("Sync operation started", directory=str(directory))
# initial paths from db to sync
# path -> checksum
if show_progress:
from rich.console import Console
console = Console()
console.print(f"Scanning directory: {directory}")
report = await self.scan(directory)
# Initialize progress tracking if requested
if show_progress and report.total > 0:
progress = Progress(
TextColumn("[bold blue]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
expand=True,
)
# order of sync matters to resolve relations effectively
logger.info(
"Sync changes detected",
new_files=len(report.new),
modified_files=len(report.modified),
deleted_files=len(report.deleted),
moved_files=len(report.moves),
)
if show_progress and report.total > 0:
with progress: # pyright: ignore
# Track each category separately
move_task = None
if report.moves: # pragma: no cover
move_task = progress.add_task("[blue]Moving files...", total=len(report.moves)) # pyright: ignore
delete_task = None
if report.deleted: # pragma: no cover
delete_task = progress.add_task( # pyright: ignore
"[red]Deleting files...", total=len(report.deleted)
)
new_task = None
if report.new:
new_task = progress.add_task( # pyright: ignore
"[green]Adding new files...", total=len(report.new)
)
modify_task = None
if report.modified: # pragma: no cover
modify_task = progress.add_task( # pyright: ignore
"[yellow]Updating modified files...", total=len(report.modified)
)
# sync moves first
for i, (old_path, new_path) in enumerate(report.moves.items()):
# in the case where a file has been deleted and replaced by another file
# it will show up in the move and modified lists, so handle it in modified
if new_path in report.modified: # pragma: no cover
report.modified.remove(new_path)
logger.debug(
"File marked as moved and modified",
old_path=old_path,
new_path=new_path,
action="processing as modified",
)
else: # pragma: no cover
await self.handle_move(old_path, new_path)
if move_task is not None: # pragma: no cover
progress.update(move_task, advance=1) # pyright: ignore
# deleted next
for i, path in enumerate(report.deleted): # pragma: no cover
await self.handle_delete(path)
if delete_task is not None: # pragma: no cover
progress.update(delete_task, advance=1) # pyright: ignore
# then new and modified
for i, path in enumerate(report.new):
await self.sync_file(path, new=True)
if new_task is not None:
progress.update(new_task, advance=1) # pyright: ignore
for i, path in enumerate(report.modified): # pragma: no cover
await self.sync_file(path, new=False)
if modify_task is not None: # pragma: no cover
progress.update(modify_task, advance=1) # pyright: ignore
# Final step - resolving relations
if report.total > 0:
relation_task = progress.add_task("[cyan]Resolving relations...", total=1) # pyright: ignore
await self.resolve_relations()
progress.update(relation_task, advance=1) # pyright: ignore
else:
# No progress display - proceed with normal sync
# sync moves first
for old_path, new_path in report.moves.items():
# in the case where a file has been deleted and replaced by another file
# it will show up in the move and modified lists, so handle it in modified
if new_path in report.modified:
report.modified.remove(new_path)
logger.debug(
"File marked as moved and modified",
old_path=old_path,
new_path=new_path,
action="processing as modified",
)
else:
await self.handle_move(old_path, new_path)
@@ -109,7 +216,16 @@ class SyncService:
await self.sync_file(path, new=False)
await self.resolve_relations()
return report
duration_ms = int((time.time() - start_time) * 1000)
logger.info(
"Sync operation completed",
directory=str(directory),
total_changes=report.total,
duration_ms=duration_ms,
)
return report
async def scan(self, directory):
"""Scan directory for changes compared to database state."""
@@ -167,25 +283,55 @@ class SyncService:
db_records = await self.entity_repository.find_all()
return {r.file_path: r.checksum or "" for r in db_records}
async def sync_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
"""Sync a single file."""
async def sync_file(
self, path: str, new: bool = True
) -> Tuple[Optional[Entity], Optional[str]]:
"""Sync a single file.
Args:
path: Path to file to sync
new: Whether this is a new file
Returns:
Tuple of (entity, checksum) or (None, None) if sync fails
"""
try:
logger.debug(
"Syncing file",
path=path,
is_new=new,
is_markdown=self.file_service.is_markdown(path),
)
if self.file_service.is_markdown(path):
entity, checksum = await self.sync_markdown_file(path, new)
else:
entity, checksum = await self.sync_regular_file(path, new)
await self.search_service.index_entity(entity)
if entity is not None:
await self.search_service.index_entity(entity)
logger.debug(
"File sync completed", path=path, entity_id=entity.id, checksum=checksum
)
return entity, checksum
except Exception as e: # pragma: no cover
logger.exception(f"Failed to sync {path}: {e}")
return None, None # pyright: ignore
logger.exception("Failed to sync file", path=path, error=str(e))
return None, None
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
"""Sync a markdown file with full proces sing."""
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
"""Sync a markdown file with full processing.
Args:
path: Path to markdown file
new: Whether this is a new file
Returns:
Tuple of (entity, checksum)
"""
# Parse markdown first to get any existing permalink
logger.debug("Parsing markdown file", path=path)
entity_markdown = await self.entity_parser.parse_file(path)
# Resolve permalink - this handles all the cases including conflicts
@@ -193,7 +339,13 @@ class SyncService:
# If permalink changed, update the file
if permalink != entity_markdown.frontmatter.permalink:
logger.info(f"Updating permalink in {path}: {permalink}")
logger.info(
"Updating permalink",
path=path,
old_permalink=entity_markdown.frontmatter.permalink,
new_permalink=permalink,
)
entity_markdown.frontmatter.metadata["permalink"] = permalink
checksum = await self.file_service.update_frontmatter(path, {"permalink": permalink})
else:
@@ -202,12 +354,14 @@ class SyncService:
# if the file is new, create an entity
if new:
# Create entity with final permalink
logger.debug(f"Creating new entity from markdown: {path}")
logger.debug("Creating new entity from markdown", path=path, permalink=permalink)
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
# otherwise we need to update the entity and observations
else:
logger.debug(f"Updating entity from markdown: {path}")
logger.debug("Updating entity from markdown", path=path, permalink=permalink)
await self.entity_service.update_entity_and_observations(Path(path), entity_markdown)
# Update relations and search index
@@ -215,11 +369,27 @@ class SyncService:
# set checksum
await self.entity_repository.update(entity.id, {"checksum": checksum})
logger.debug(
"Markdown sync completed",
path=path,
entity_id=entity.id,
observation_count=len(entity.observations),
relation_count=len(entity.relations),
)
return entity, checksum
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
"""Sync a non-markdown file with basic tracking."""
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
"""Sync a non-markdown file with basic tracking.
Args:
path: Path to file
new: Whether this is a new file
Returns:
Tuple of (entity, checksum)
"""
checksum = await self.file_service.compute_checksum(path)
if new:
# Generate permalink from path
@@ -248,11 +418,18 @@ class SyncService:
return entity, checksum
else:
entity = await self.entity_repository.get_by_file_path(path)
assert entity is not None, "entity should not be None for existing file"
if entity is None: # pragma: no cover
logger.error("Entity not found for existing file", path=path)
raise ValueError(f"Entity not found for existing file: {path}")
updated = await self.entity_repository.update(
entity.id, {"file_path": path, "checksum": checksum}
)
assert updated is not None, "entity should be updated"
if updated is None: # pragma: no cover
logger.error("Failed to update entity", entity_id=entity.id, path=path)
raise ValueError(f"Failed to update entity with ID {entity.id}")
return updated, checksum
async def handle_delete(self, file_path: str):
@@ -261,7 +438,12 @@ class SyncService:
# First get entity to get permalink before deletion
entity = await self.entity_repository.get_by_file_path(file_path)
if entity:
logger.debug(f"Deleting entity and cleaning up search index: {file_path}")
logger.info(
"Deleting entity",
file_path=file_path,
entity_id=entity.id,
permalink=entity.permalink,
)
# Delete from db (this cascades to observations/relations)
await self.entity_service.delete_entity_by_file_path(file_path)
@@ -272,7 +454,14 @@ class SyncService:
+ [o.permalink for o in entity.observations]
+ [r.permalink for r in entity.relations]
)
logger.debug(f"Deleting from search index: {permalinks}")
logger.debug(
"Cleaning up search index",
entity_id=entity.id,
file_path=file_path,
index_entries=len(permalinks),
)
for permalink in permalinks:
if permalink:
await self.search_service.delete_by_permalink(permalink)
@@ -280,12 +469,30 @@ class SyncService:
await self.search_service.delete_by_entity_id(entity.id)
async def handle_move(self, old_path, new_path):
logger.debug(f"Moving entity: {old_path} -> {new_path}")
logger.info("Moving entity", old_path=old_path, new_path=new_path)
entity = await self.entity_repository.get_by_file_path(old_path)
if entity:
# Update file_path but keep the same permalink for link stability
updated = await self.entity_repository.update(entity.id, {"file_path": new_path})
assert updated is not None, "entity should be updated"
if updated is None: # pragma: no cover
logger.error(
"Failed to update entity path",
entity_id=entity.id,
old_path=old_path,
new_path=new_path,
)
raise ValueError(f"Failed to update entity path for ID {entity.id}")
logger.debug(
"Entity path updated",
entity_id=entity.id,
permalink=entity.permalink,
old_path=old_path,
new_path=new_path,
)
# update search index
await self.search_service.index_entity(updated)
@@ -293,14 +500,28 @@ class SyncService:
"""Try to resolve any unresolved relations"""
unresolved_relations = await self.relation_repository.find_unresolved_relations()
logger.debug(f"Attempting to resolve {len(unresolved_relations)} forward references")
logger.info("Resolving forward references", count=len(unresolved_relations))
for relation in unresolved_relations:
logger.debug(
"Attempting to resolve relation",
relation_id=relation.id,
from_id=relation.from_id,
to_name=relation.to_name,
)
resolved_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
# ignore reference to self
if resolved_entity and resolved_entity.id != relation.from_id:
logger.debug(
f"Resolved forward reference: {relation.to_name} -> {resolved_entity.title}"
"Resolved forward reference",
relation_id=relation.id,
from_id=relation.from_id,
to_name=relation.to_name,
resolved_id=resolved_entity.id,
resolved_title=resolved_entity.title,
)
try:
await self.relation_repository.update(
@@ -311,7 +532,12 @@ class SyncService:
},
)
except IntegrityError: # pragma: no cover
logger.debug(f"Ignoring duplicate relation {relation}")
logger.debug(
"Ignoring duplicate relation",
relation_id=relation.id,
from_id=relation.from_id,
to_name=relation.to_name,
)
# update search index
await self.search_service.index_entity(resolved_entity)
@@ -326,8 +552,11 @@ class SyncService:
Returns:
ScanResult containing found files and any errors
"""
import time
logger.debug(f"Scanning directory: {directory}")
start_time = time.time()
logger.debug("Scanning directory", directory=str(directory))
result = ScanResult()
for root, dirnames, filenames in os.walk(str(directory)):
@@ -344,6 +573,15 @@ class SyncService:
checksum = await self.file_service.compute_checksum(rel_path)
result.files[rel_path] = checksum
result.checksums[checksum] = rel_path
logger.debug(f"Found file: {rel_path} with checksum: {checksum}")
logger.debug("Found file", path=rel_path, checksum=checksum)
duration_ms = int((time.time() - start_time) * 1000)
logger.debug(
"Directory scan completed",
directory=str(directory),
files_found=len(result.files),
duration_ms=duration_ms,
)
return result
+151 -28
View File
@@ -1,6 +1,5 @@
"""Watch service for Basic Memory."""
import dataclasses
import os
from datetime import datetime
from pathlib import Path
@@ -29,8 +28,8 @@ class WatchEvent(BaseModel):
class WatchServiceState(BaseModel):
# Service status
running: bool = False
start_time: datetime = dataclasses.field(default_factory=datetime.now)
pid: int = dataclasses.field(default_factory=os.getpid)
start_time: datetime = datetime.now() # Use directly with Pydantic model
pid: int = os.getpid() # Use directly with Pydantic model
# Stats
error_count: int = 0
@@ -41,7 +40,7 @@ class WatchServiceState(BaseModel):
synced_files: int = 0
# Recent activity
recent_events: List[WatchEvent] = dataclasses.field(default_factory=list)
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
def add_event(
self,
@@ -81,10 +80,17 @@ class WatchService:
async def run(self): # pragma: no cover
"""Watch for file changes and sync them"""
logger.info("Watching for sync changes")
logger.info(
"Watch service started",
directory=str(self.config.home),
debounce_ms=self.config.sync_delay,
pid=os.getpid(),
)
self.state.running = True
self.state.start_time = datetime.now()
await self.write_status()
try:
async for changes in awatch(
self.config.home,
@@ -95,14 +101,23 @@ class WatchService:
await self.handle_changes(self.config.home, changes)
except Exception as e:
logger.exception("Watch service error", error=str(e), directory=str(self.config.home))
self.state.record_error(str(e))
await self.write_status()
raise
finally:
logger.info(
"Watch service stopped",
directory=str(self.config.home),
runtime_seconds=int((datetime.now() - self.state.start_time).total_seconds()),
)
self.state.running = False
await self.write_status()
def filter_changes(self, change: Change, path: str) -> bool:
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
"""Filter to only watch non-hidden files and directories.
Returns:
@@ -112,6 +127,7 @@ class WatchService:
try:
relative_path = Path(path).relative_to(self.config.home)
except ValueError:
# This is a defensive check for paths outside our home directory
return False
# Skip hidden directories and files
@@ -128,12 +144,17 @@ class WatchService:
async def handle_changes(self, directory: Path, changes: Set[FileChange]):
"""Process a batch of file changes"""
logger.debug(f"handling {len(changes)} changes in directory: {directory} ...")
import time
from typing import List, Set
start_time = time.time()
logger.info("Processing file changes", change_count=len(changes), directory=str(directory))
# Group changes by type
adds = []
deletes = []
modifies = []
adds: List[str] = []
deletes: List[str] = []
modifies: List[str] = []
for change, path in changes:
# convert to relative path
@@ -145,25 +166,44 @@ class WatchService:
elif change == Change.modified:
modifies.append(relative_path)
logger.debug(
"Grouped file changes", added=len(adds), deleted=len(deletes), modified=len(modifies)
)
# Track processed files to avoid duplicates
processed = set()
processed: Set[str] = set()
# First handle potential moves
for added_path in adds:
if added_path in processed:
continue # pragma: no cover
# Skip directories for added paths
# We don't need to process directories, only the files inside them
# This prevents errors when trying to compute checksums or read directories as files
added_full_path = directory / added_path
if added_full_path.is_dir():
logger.debug("Skipping directory for move detection", path=added_path)
processed.add(added_path)
continue
for deleted_path in deletes:
if deleted_path in processed:
continue # pragma: no cover
# Skip directories for deleted paths (based on entity type in db)
deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
deleted_path
)
if deleted_entity is None:
# If this was a directory, it wouldn't have an entity
logger.debug("Skipping unknown path for move detection", path=deleted_path)
continue
if added_path != deleted_path:
# Compare checksums to detect moves
try:
added_checksum = await self.file_service.compute_checksum(added_path)
deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
deleted_path
)
if deleted_entity and deleted_entity.checksum == added_checksum:
await self.sync_service.handle_move(deleted_path, added_path)
@@ -172,48 +212,131 @@ class WatchService:
action="moved",
status="success",
)
self.console.print(
f"[blue]→[/blue] Moved: {deleted_path}{added_path}"
)
self.console.print(f"[blue]→[/blue] {deleted_path}{added_path}")
processed.add(added_path)
processed.add(deleted_path)
break
except Exception as e: # pragma: no cover
logger.warning(f"Error checking for move: {e}")
logger.warning(
"Error checking for move",
old_path=deleted_path,
new_path=added_path,
error=str(e),
)
# Handle remaining changes
# Handle remaining changes - group them by type for concise output
moved_count = len([p for p in processed if p in deletes or p in adds])
delete_count = 0
add_count = 0
modify_count = 0
# Process deletes
for path in deletes:
if path not in processed:
logger.debug("Processing deleted file", path=path)
await self.sync_service.handle_delete(path)
self.state.add_event(path=path, action="deleted", status="success")
self.console.print(f"[red]✕[/red] Deleted: {path}")
self.console.print(f"[red]✕[/red] {path}")
processed.add(path)
delete_count += 1
# Process adds
for path in adds:
if path not in processed:
_, checksum = await self.sync_service.sync_file(path, new=True)
# Skip directories - only process files
full_path = directory / path
if full_path.is_dir(): # pragma: no cover
logger.debug("Skipping directory", path=path)
processed.add(path)
continue
logger.debug("Processing new file", path=path)
entity, checksum = await self.sync_service.sync_file(path, new=True)
if checksum:
self.state.add_event(
path=path, action="new", status="success", checksum=checksum
)
self.console.print(f"[green]✓[/green] Added: {path}")
self.console.print(f"[green]✓[/green] {path}")
logger.debug(
"Added file processed",
path=path,
entity_id=entity.id if entity else None,
checksum=checksum,
)
processed.add(path)
else:
self.console.print(f"[orange]?[/orange] Error syncing: {path}")
add_count += 1
else: # pragma: no cover
logger.warning("Error syncing new file", path=path) # pragma: no cover
self.console.print(
f"[orange]?[/orange] Error syncing: {path}"
) # pragma: no cover
# Process modifies - detect repeats
last_modified_path = None
repeat_count = 0
for path in modifies:
if path not in processed:
_, checksum = await self.sync_service.sync_file(path, new=False)
# Skip directories - only process files
full_path = directory / path
if full_path.is_dir():
logger.debug("Skipping directory", path=path)
processed.add(path)
continue
logger.debug("Processing modified file", path=path)
entity, checksum = await self.sync_service.sync_file(path, new=False)
self.state.add_event(
path=path, action="modified", status="success", checksum=checksum
)
self.console.print(f"[yellow]✎[/yellow] Modified: {path}")
# Check if this is a repeat of the last modified file
if path == last_modified_path: # pragma: no cover
repeat_count += 1 # pragma: no cover
# Only show a message for the first repeat
if repeat_count == 1: # pragma: no cover
self.console.print(
f"[yellow]...[/yellow] Repeated changes to {path}"
) # pragma: no cover
else:
# New file being modified
self.console.print(f"[yellow]✎[/yellow] {path}")
last_modified_path = path
repeat_count = 0
modify_count += 1
logger.debug(
"Modified file processed",
path=path,
entity_id=entity.id if entity else None,
checksum=checksum,
)
processed.add(path)
# Add a divider if we processed any files
# Add a concise summary instead of a divider
if processed:
self.console.print("" * 80, style="dim")
changes = [] # pyright: ignore
if add_count > 0:
changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
if modify_count > 0:
changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
if moved_count > 0:
changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
if delete_count > 0:
changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
if changes:
self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
duration_ms = int((time.time() - start_time) * 1000)
self.state.last_scan = datetime.now()
self.state.synced_files += len(processed)
logger.info(
"File change processing completed",
processed_files=len(processed),
total_synced_files=self.state.synced_files,
duration_ms=duration_ms,
)
await self.write_status()
+52 -38
View File
@@ -1,30 +1,43 @@
"""Utility functions for basic-memory."""
import logging
# Set environment variable before importing logfire to suppress warnings
import os
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
import logging
import re
import sys
from pathlib import Path
from typing import Optional, Union
from typing import Optional, Protocol, Union, runtime_checkable
from loguru import logger
from unidecode import unidecode
import basic_memory
import logfire
@runtime_checkable
class PathLike(Protocol):
"""Protocol for objects that can be used as paths."""
def __str__(self) -> str: ...
# In type annotations, use Union[Path, str] instead of FilePath for now
# This preserves compatibility with existing code while we migrate
FilePath = Union[Path, str]
# Disable the "Queue is full" warning
logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
# Disable logfire prompts in CI/automated environments
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
def generate_permalink(file_path: Union[Path, str]) -> str:
def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path
file_path: Original file path (str, Path, or PathLike)
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
@@ -78,44 +91,45 @@ def setup_logging(
) -> None: # pragma: no cover
"""
Configure logging for the application.
:param home_dir: the root directory for the application
:param log_file: the name of the log file to write to
:param app: the fastapi application instance
:param console: whether to log to the console
"""
Args:
env: The environment name (dev, test, prod)
home_dir: The root directory for the application
log_file: The name of the log file to write to
log_level: The logging level to use
console: Whether to log to the console
"""
# Remove default handler and any existing handlers
logger.remove()
# Add file handler if we are not running tests
# Add file handler if we are not running tests and a log file is specified
if log_file and env != "test":
try:
# Skip logfire configuration if LOGFIRE_API_KEY is not set
# This avoids interactive prompts when running automated tasks
if "LOGFIRE_API_KEY" in os.environ:
# enable pydantic logfire
# Only configure logfire if API key is set - avoids interactive prompts
if "LOGFIRE_TOKEN" in os.environ:
# Configure logfire with code source info
logfire.configure(
code_source=logfire.CodeSource(
repository="https://github.com/basicmachines-co/basic-memory",
revision=basic_memory.__version__,
revision=f"v{basic_memory.__version__}" if env != "dev" else "HEAD",
),
environment=env,
console=False,
)
logger.configure(handlers=[logfire.loguru_handler()])
# instrument code spans
# Instrument code spans for better observability
logfire.instrument_sqlite3()
logfire.instrument_httpx()
except Exception as e:
logger.warning(f"Failed to configure logfire: {e}")
# setup logger
# Setup file logger
log_path = home_dir / log_file
logger.add(
str(log_path),
level=log_level,
rotation="100 MB",
rotation="10 MB",
retention="10 days",
backtrace=True,
diagnose=True,
@@ -123,26 +137,26 @@ def setup_logging(
colorize=False,
)
# Add console logger if requested or in test mode
if env == "test" or console:
# Add stderr handler
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
# Get the logger for 'httpx'
httpx_logger = logging.getLogger("httpx")
# Set the logging level to WARNING to ignore INFO and DEBUG logs
httpx_logger.setLevel(logging.WARNING)
# Reduce noise from third-party libraries
noisy_loggers = {
# HTTP client logs
"httpx": logging.WARNING,
# File watching logs
"watchfiles.main": logging.WARNING,
# Instrumentation noise
"instrumentor": logging.ERROR,
"opentelemetry.instrumentation.instrumentor": logging.ERROR,
"opentelemetry.instrumentation": logging.ERROR,
"logfire.instrumentor": logging.ERROR,
"opentelemetry.sdk.metrics._internal.instrument": logging.ERROR,
}
# turn watchfiles to WARNING
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
# Disable all instrumentor-related warnings
for logger_name in [
"instrumentor",
"opentelemetry.instrumentation.instrumentor",
"opentelemetry.instrumentation",
"logfire.instrumentor",
"opentelemetry.sdk.metrics._internal.instrument",
]:
logging.getLogger(logger_name).setLevel(logging.ERROR)
# Set log levels for noisy loggers
for logger_name, level in noisy_loggers.items():
logging.getLogger(logger_name).setLevel(level)
+88 -3
View File
@@ -3,10 +3,12 @@
These tests use real MCP tools with the test environment instead of mocks.
"""
import io
from datetime import datetime, timedelta
import json
from textwrap import dedent
from typing import AsyncGenerator
from unittest.mock import patch
import pytest
import pytest_asyncio
@@ -14,7 +16,7 @@ from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from typer.testing import CliRunner
from basic_memory.cli.commands.tools import tool_app
from basic_memory.cli.commands.tool import tool_app
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_project_config, get_engine_factory
@@ -125,6 +127,90 @@ def test_write_note_with_tags(cli_env, test_config):
assert "tag1, tag2" in result.stdout or "tag1" in result.stdout and "tag2" in result.stdout
def test_write_note_from_stdin(cli_env, test_config, monkeypatch):
"""Test write_note command reading from stdin.
This test requires minimal mocking of stdin to simulate piped input.
"""
test_content = "This is content from stdin for testing"
# Mock stdin using monkeypatch, which works better with typer's CliRunner
monkeypatch.setattr("sys.stdin", io.StringIO(test_content))
monkeypatch.setattr("sys.stdin.isatty", lambda: False) # Simulate piped input
# Use runner.invoke with input parameter as a fallback
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Stdin Test Note",
"--folder",
"test",
],
input=test_content, # Provide input as a fallback
)
assert result.exit_code == 0
# Check for expected success message
assert "Stdin Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
assert "permalink" in result.stdout
def test_write_note_content_param_priority(cli_env, test_config):
"""Test that content parameter has priority over stdin."""
stdin_content = "This content from stdin should NOT be used"
param_content = "This explicit content parameter should be used"
# Mock stdin but provide explicit content parameter
with (
patch("sys.stdin", io.StringIO(stdin_content)),
patch("sys.stdin.isatty", return_value=False),
): # Simulate piped input
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Priority Test Note",
"--content",
param_content,
"--folder",
"test",
],
)
assert result.exit_code == 0
# Check the note was created with the content from parameter, not stdin
# We can't directly check file contents in this test approach
# but we can verify the command succeeded
assert "Priority Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
def test_write_note_no_content(cli_env, test_config):
"""Test error handling when no content is provided."""
# Mock stdin to appear as a terminal, not a pipe
with patch("sys.stdin.isatty", return_value=True):
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"No Content Note",
"--folder",
"test",
],
)
# Should exit with an error
assert result.exit_code == 1
# assert "No content provided" in result.stderr
def test_read_note(cli_env, setup_test_note):
"""Test read_note command."""
permalink = setup_test_note["permalink"]
@@ -349,5 +435,4 @@ def test_continue_conversation_no_results(cli_env):
# Check result contains expected content for no results
assert "Continuing conversation on: NonexistentTopic" in result.stdout
assert "I couldn't find any recent work specifically on this topic" in result.stdout
assert "Try a different search term" in result.stdout
assert "The supplied query did not return any information" in result.stdout
+158
View File
@@ -0,0 +1,158 @@
"""Tests for project CLI commands."""
import json
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from typer.testing import CliRunner
from basic_memory.cli.main import app
from basic_memory.config import ConfigManager, DATA_DIR_NAME, CONFIG_FILE_NAME
@pytest.fixture
def temp_home(monkeypatch):
"""Create a temporary directory for testing."""
with TemporaryDirectory() as tempdir:
temp_home = Path(tempdir)
monkeypatch.setattr(Path, "home", lambda: temp_home)
# Ensure config directory exists
config_dir = temp_home / DATA_DIR_NAME
config_dir.mkdir(parents=True, exist_ok=True)
yield temp_home
@pytest.fixture
def cli_runner():
"""Create a CLI runner for testing."""
return CliRunner()
def test_project_list_empty(cli_runner, temp_home):
"""Test listing projects when none are configured."""
# Create empty config but with main project (will always be present)
config_file = temp_home / DATA_DIR_NAME / CONFIG_FILE_NAME
config_file.write_text(json.dumps({"projects": {}, "default_project": "main"}))
# Run command
result = cli_runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
# The test will always have at least the "main" project due to auto-initialization
assert "main" in result.stdout
def test_project_list(cli_runner, temp_home):
"""Test listing projects."""
# Create config with projects
config_file = temp_home / DATA_DIR_NAME / CONFIG_FILE_NAME
config_data = {
"projects": {
"main": str(temp_home / "basic-memory"),
"work": str(temp_home / "work-memory"),
},
"default_project": "main",
}
config_file.write_text(json.dumps(config_data))
# Run command
result = cli_runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
assert "main" in result.stdout
assert "work" in result.stdout
assert "basic-memory" in result.stdout
assert "work-memory" in result.stdout
def test_project_add(cli_runner, temp_home):
"""Test adding a project."""
# Create config manager to initialize config
config_manager = ConfigManager()
# Create a project directory
test_project_dir = temp_home / "test-project"
# Run command
result = cli_runner.invoke(app, ["project", "add", "test", str(test_project_dir)])
assert result.exit_code == 0
assert "Project 'test' added at" in result.stdout
# Verify project was added
config_manager = ConfigManager()
assert "test" in config_manager.projects
assert Path(config_manager.projects["test"]) == test_project_dir
def test_project_add_existing(cli_runner, temp_home):
"""Test adding a project that already exists."""
# Create config manager and add a project
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Try to add the same project again
result = cli_runner.invoke(app, ["project", "add", "test", str(temp_home / "another-path")])
assert result.exit_code == 1
assert "Error: Project 'test' already exists" in result.stdout
def test_project_remove(cli_runner, temp_home):
"""Test removing a project."""
# Create config manager and add a project
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Remove the project
result = cli_runner.invoke(app, ["project", "remove", "test"])
assert result.exit_code == 0
assert "Project 'test' removed" in result.stdout
# Verify project was removed
config_manager = ConfigManager()
assert "test" not in config_manager.projects
def test_project_default(cli_runner, temp_home):
"""Test setting the default project."""
# Create config manager and add a project
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Set as default
result = cli_runner.invoke(app, ["project", "default", "test"])
assert result.exit_code == 0
assert "Project 'test' set as default" in result.stdout
# Verify default was set
config_manager = ConfigManager()
assert config_manager.default_project == "test"
def test_project_current(cli_runner, temp_home):
"""Test showing the current project."""
# Set as default
result = cli_runner.invoke(app, ["project", "current"])
assert result.exit_code == 0
assert "Current project: main" in result.stdout
assert "Path:" in result.stdout
assert "Database:" in result.stdout
def test_project_option(cli_runner, temp_home, monkeypatch):
"""Test using the --project option."""
# Create config manager and add a project
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Mock environment to capture the set variable
env_vars = {}
monkeypatch.setattr(os, "environ", env_vars)
# Run command with --project option
cli_runner.invoke(app, ["--project", "test", "project", "current"])
# Verify environment variable was set
assert env_vars.get("BASIC_MEMORY_PROJECT") == "test"
+14
View File
@@ -0,0 +1,14 @@
"""Tests for CLI sync command."""
from typer.testing import CliRunner
from basic_memory.cli.app import app
# Set up CLI runner
runner = CliRunner()
def test_version_arg():
"""Test the version arg."""
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
+1 -1
View File
@@ -349,5 +349,5 @@ def test_files(test_config) -> dict[str, Path]:
@pytest_asyncio.fixture
async def synced_files(sync_service, test_config, test_files):
# Initial sync - should create forward reference
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
return test_files
+157 -3
View File
@@ -3,6 +3,9 @@
import pytest
from basic_memory.mcp.prompts.continue_conversation import continue_conversation
from basic_memory.mcp.prompts.search import search_prompt, format_search_results
from basic_memory.mcp.prompts.recent_activity import recent_activity_prompt
from basic_memory.schemas.search import SearchResponse, SearchResult, SearchItemType
@pytest.mark.asyncio
@@ -41,8 +44,7 @@ async def test_continue_conversation_no_results(client):
# Check the response indicates no results found
assert "Continuing conversation on: NonExistentTopic" in result
assert "I couldn't find any recent work specifically on this topic" in result
assert "Try a different search term" in result
assert "The supplied query did not return any information" in result
@pytest.mark.asyncio
@@ -58,4 +60,156 @@ async def test_continue_conversation_creates_structured_suggestions(client, test
assert "read_note" in result
assert "search" in result
assert "recent_activity" in result
assert "build_context" in result
# Search prompt tests
@pytest.mark.asyncio
async def test_search_prompt_with_results(client, test_graph):
"""Test search_prompt with a query that returns results."""
# Call the function with a query that should match existing content
result = await search_prompt("Root")
# Check the response contains expected content
assert 'Search Results for: "Root"' in result
assert "I found " in result
assert "You can view this content with: `read_note" in result
assert "Synthesize and Capture Knowledge" in result
@pytest.mark.asyncio
async def test_search_prompt_with_timeframe(client, test_graph):
"""Test search_prompt with a timeframe."""
# Call the function with a query and timeframe
result = await search_prompt("Root", timeframe="1w")
# Check the response includes timeframe information
assert 'Search Results for: "Root" (after 1w)' in result
assert "I found " in result
@pytest.mark.asyncio
async def test_search_prompt_no_results(client):
"""Test search_prompt when no results are found."""
# Call with a query that won't match anything
result = await search_prompt("XYZ123NonExistentQuery")
# Check the response indicates no results found
assert 'Search Results for: "XYZ123NonExistentQuery"' in result
assert "I couldn't find any results for this query" in result
assert "Opportunity to Capture Knowledge" in result
assert "write_note" in result
@pytest.mark.asyncio
async def test_format_search_results_with_results():
"""Test format_search_results with search results."""
# Create a mock SearchResponse with results
search_response = SearchResponse(
results=[
SearchResult(
entity="test-entity",
type=SearchItemType.ENTITY,
title="Test Result",
permalink="test-result",
file_path="test_result.md",
content="This is test content",
score=0.95,
metadata={"created_at": "2023-01-01"},
)
],
current_page=1,
page_size=10,
)
# Format the results
result = format_search_results("test query", search_response)
# Check the formatted output
assert 'Search Results for: "test query"' in result
assert "I found 1 results" in result
assert "Test Result" in result
assert "This is test content" in result
@pytest.mark.asyncio
async def test_format_search_results_no_results():
"""Test format_search_results with no search results."""
# Create a mock SearchResponse with no results
search_response = SearchResponse(results=[], current_page=1, page_size=10)
# Format the results
result = format_search_results("empty query", search_response)
# Check the formatted output
assert 'Search Results for: "empty query"' in result
assert "I couldn't find any results for this query" in result
assert "Opportunity to Capture Knowledge" in result
# Test utils
def test_prompt_context_with_file_path_no_permalink():
"""Test format_prompt_context with items that have file_path but no permalink."""
from basic_memory.mcp.prompts.utils import (
format_prompt_context,
PromptContext,
PromptContextItem,
)
from basic_memory.schemas.memory import EntitySummary
# Create a mock context with a file that has no permalink (like a binary file)
test_entity = EntitySummary(
id="1",
type="file",
title="Test File",
permalink=None, # No permalink
file_path="test_file.pdf",
created_at="2023-01-01",
updated_at="2023-01-01",
)
context = PromptContext(
topic="Test Topic",
timeframe="1d",
results=[
PromptContextItem(
primary_results=[test_entity],
related_results=[test_entity], # Also use as related
)
],
)
# Format the context
result = format_prompt_context(context)
# Check that file_path is used when permalink is missing
assert "test_file.pdf" in result
assert "read_file" in result
# Recent activity prompt tests
@pytest.mark.asyncio
async def test_recent_activity_prompt(client, test_graph):
"""Test recent_activity_prompt."""
# Call the function
result = await recent_activity_prompt(timeframe="1w")
# Check the response contains expected content
assert "Recent Activity" in result
assert "Opportunity to Capture Activity Summary" in result
assert "write_note" in result
@pytest.mark.asyncio
async def test_recent_activity_prompt_with_custom_timeframe(client, test_graph):
"""Test recent_activity_prompt with custom timeframe."""
# Call the function with a custom timeframe
result = await recent_activity_prompt(timeframe="1d")
# Check the response includes the custom timeframe
assert "Recent Activity from (1d)" in result
-18
View File
@@ -1,27 +1,9 @@
from basic_memory.mcp.prompts.json_canvas_spec import json_canvas_spec
from basic_memory.mcp.prompts.ai_assistant_guide import ai_assistant_guide
import pytest
@pytest.mark.asyncio
async def test_canvas_spec_resource_exists(app):
"""Test that the canvas spec resource exists and returns content."""
# Call the resource function
spec_content = json_canvas_spec()
# Verify basic characteristics of the content
assert spec_content is not None
assert isinstance(spec_content, str)
assert len(spec_content) > 0
# Verify it contains expected sections of the Canvas spec
assert "JSON Canvas Spec" in spec_content
assert "nodes" in spec_content
assert "edges" in spec_content
@pytest.mark.asyncio
async def test_ai_assistant_guide_exists(app):
"""Test that the canvas spec resource exists and returns content."""
+16 -158
View File
@@ -3,7 +3,6 @@
from textwrap import dedent
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import write_note, read_note, delete_note
@@ -86,13 +85,6 @@ async def test_write_note_no_tags(app):
)
@pytest.mark.asyncio
async def test_read_note_not_found(app):
"""Test trying to read a non-existent note."""
with pytest.raises(ToolError, match="Resource not found"):
await read_note("notes/does-not-exist")
@pytest.mark.asyncio
async def test_write_note_update_existing(app):
"""Test creating a new note.
@@ -130,7 +122,7 @@ async def test_write_note_update_existing(app):
)
assert (
dedent("""
# Updated test/Test Note.md (131b5662)
# Updated test/Test Note.md (a8eb4d44)
permalink: test/test-note
## Tags
@@ -142,115 +134,25 @@ async def test_write_note_update_existing(app):
# Try reading it back
content = await read_note("test/test-note")
assert (
dedent(
"""
---
title: Test Note
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
---
# Test
This is an updated note
"""
---
permalink: test/test-note
tags:
- '#test'
- '#documentation'
title: Test Note
type: note
---
# Test
This is an updated note
""".strip()
in content
).strip()
== content
)
@pytest.mark.asyncio
async def test_read_note_by_title(app):
"""Test reading a note by its title."""
# First create a note
await write_note(title="Special Note", folder="test", content="Note content here")
# Should be able to read it by title
content = await read_note("Special Note")
assert "Note content here" in content
@pytest.mark.asyncio
async def test_note_unicode_content(app):
"""Test handling of unicode content in"""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
result = await 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 read_note("test/unicode-test")
assert content in result
@pytest.mark.asyncio
async def test_multiple_notes(app):
"""Test creating and managing multiple"""
# Create several notes
notes_data = [
("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", []),
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
assert content in note
# read multiple notes at once
result = await 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
async def test_multiple_notes_pagination(app):
"""Test creating and managing multiple"""
# Create several notes
notes_data = [
("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", []),
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
assert content in note
# read multiple notes at once with pagination
result = await read_note("test/*", page=1, page_size=2)
# 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
@pytest.mark.asyncio
async def test_delete_note_existing(app):
"""Test deleting a new note.
@@ -327,47 +229,3 @@ async def test_write_note_verbose(app):
""").strip()
in result
)
@pytest.mark.asyncio
async def test_read_note_memory_url(app):
"""Test reading a note using a memory:// URL.
Should:
- Handle memory:// URLs correctly
- Normalize the URL before resolving
- Return the note content
"""
# First create a note
result = await 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 = "memory://test/memory-url-test"
content = await read_note(memory_url)
assert "Testing memory:// URL handling" in content
@pytest.mark.asyncio
async def test_read_note_non_error_status(app, mocker):
"""Test scenario where read_note gets a non-200 status code that doesn't raise an exception.
This tests the specific path that returns an error message for non-200 status
when we don't have an exception.
"""
# Create a mock response with a non-200 status that doesn't raise an exception
mock_response = mocker.MagicMock()
mock_response.status_code = 204 # No content
# Mock the call_get function to return our mock response
mocker.patch("basic_memory.mcp.tools.read_note.call_get", return_value=mock_response)
# Call read_note which should hit our error message path
result = await read_note("test/non-existing-note")
# Verify the error message format
assert result == "Error: Could not find entity at test/non-existing-note"
+291
View File
@@ -0,0 +1,291 @@
"""Tests for note tools that exercise the full stack with SQLite."""
from textwrap import dedent
import pytest
from basic_memory.mcp.tools import write_note, read_note
import pytest_asyncio
from unittest.mock import MagicMock, patch
from basic_memory.schemas.search import SearchResponse, SearchItemType
@pytest_asyncio.fixture
async def mock_call_get():
"""Mock for call_get to simulate different responses."""
with patch("basic_memory.mcp.tools.read_note.call_get") as mock:
# Default to 404 - not found
mock_response = MagicMock()
mock_response.status_code = 404
mock.return_value = mock_response
yield mock
@pytest_asyncio.fixture
async def mock_search():
"""Mock for search tool."""
with patch("basic_memory.mcp.tools.read_note.search") as mock:
# Default to empty results
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
yield mock
@pytest.mark.asyncio
async def test_read_note_by_title(app):
"""Test reading a note by its title."""
# First create a note
await write_note(title="Special Note", folder="test", content="Note content here")
# Should be able to read it by title
content = await read_note("Special Note")
assert "Note content here" in content
@pytest.mark.asyncio
async def test_note_unicode_content(app):
"""Test handling of unicode content in"""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
result = await 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 read_note("test/unicode-test")
assert content in result
@pytest.mark.asyncio
async def test_multiple_notes(app):
"""Test creating and managing multiple"""
# Create several notes
notes_data = [
("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", []),
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
assert content in note
# read multiple notes at once
result = await 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
async def test_multiple_notes_pagination(app):
"""Test creating and managing multiple"""
# Create several notes
notes_data = [
("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", []),
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
assert content in note
# read multiple notes at once with pagination
result = await read_note("test/*", page=1, page_size=2)
# 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
@pytest.mark.asyncio
async def test_read_note_memory_url(app):
"""Test reading a note using a memory:// URL.
Should:
- Handle memory:// URLs correctly
- Normalize the URL before resolving
- Return the note content
"""
# First create a note
result = await 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 = "memory://test/memory-url-test"
content = await read_note(memory_url)
assert "Testing memory:// URL handling" in content
@pytest.mark.asyncio
async def test_read_note_direct_success(mock_call_get):
"""Test read_note with successful direct permalink lookup."""
# Setup mock for successful response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "# Test Note\n\nThis is a test note."
mock_call_get.return_value = mock_response
# Call the function
result = await read_note("test/test-note")
# Verify direct lookup was used
mock_call_get.assert_called_once()
assert "test/test-note" in mock_call_get.call_args[0][1]
# Verify result
assert "# Test Note" in result
assert "This is a test note." in result
@pytest.mark.asyncio
async def test_read_note_title_search_fallback(mock_call_get, mock_search):
"""Test read_note falls back to title search when direct lookup fails."""
# Setup mock for failed direct lookup
mock_call_get.side_effect = [
# First call fails (direct lookup)
MagicMock(status_code=404),
# Second call succeeds (after title search)
MagicMock(status_code=200, text="# Test Note\n\nThis is a test note."),
]
# Setup mock for successful title search
mock_search.return_value = SearchResponse(
results=[
{
"id": 1,
"entity": "test/test-note",
"title": "Test Note",
"type": SearchItemType.ENTITY,
"permalink": "test/test-note",
"file_path": "test/test-note.md",
"score": 1.0,
}
],
current_page=1,
page_size=1,
)
# Call the function
result = await read_note("Test Note")
# Verify title search was used
mock_search.assert_called_once()
assert mock_search.call_args[0][0].title == "Test Note"
# Verify second lookup was used
assert mock_call_get.call_count == 2
assert "test/test-note" in mock_call_get.call_args[0][1]
# Verify result
assert "# Test Note" in result
assert "This is a test note." in result
@pytest.mark.asyncio
async def test_read_note_text_search_fallback(mock_call_get, mock_search):
"""Test read_note falls back to text search and returns related results."""
# Setup mock for failed direct and title lookups
mock_call_get.return_value = MagicMock(status_code=404)
# Setup mock for failed title search but successful text search
mock_search.side_effect = [
# First call (title search) returns no results
SearchResponse(results=[], current_page=1, page_size=1),
# Second call (text search) returns results
SearchResponse(
results=[
{
"id": 1,
"title": "Related Note 1",
"entity": "notes/related-note-1",
"type": SearchItemType.ENTITY,
"permalink": "notes/related-note-1",
"file_path": "notes/related-note-1.md",
"score": 0.8,
},
{
"id": 2,
"title": "Related Note 2",
"entity": "notes/related-note-2",
"type": SearchItemType.ENTITY,
"permalink": "notes/related-note-2",
"file_path": "notes/related-note-2.md",
"score": 0.7,
},
],
current_page=1,
page_size=1,
),
]
# Call the function
result = await read_note("some query")
# Verify both search types were used
assert mock_search.call_count == 2
assert mock_search.call_args_list[0][0][0].title == "some query" # Title search
assert mock_search.call_args_list[1][0][0].text == "some query" # Text search
# Verify result contains helpful information
assert "Note Not Found" in result
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 "write_note(" in result
@pytest.mark.asyncio
async def test_read_note_complete_fallback(mock_call_get, mock_search):
"""Test read_note with all lookups failing."""
# Setup mock for failed direct lookup
mock_call_get.return_value = MagicMock(status_code=404)
# Setup mock for failed searches
mock_search.return_value = SearchResponse(results=[], current_page=1, page_size=1)
# Call the function
result = await read_note("nonexistent")
# Verify search was used
assert mock_search.call_count == 2
# Verify result contains helpful guidance
assert "Note Not Found" in result
assert "nonexistent" in result
assert "Check Identifier Type" in result
assert "Search Instead" in result
assert "Recent Activity" in result
assert "Create New Note" in result
assert "write_note(" in result
+77 -41
View File
@@ -1,6 +1,7 @@
"""Tests for EntityService."""
from pathlib import Path
from textwrap import dedent
import pytest
import yaml
@@ -375,21 +376,25 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser
@pytest.mark.asyncio
async def test_create_with_content(entity_service: EntityService, file_service: FileService):
content = """---
permalink: git-workflow-guide
---
# Git Workflow Guide
# contains frontmatter
content = dedent(
"""
---
permalink: git-workflow-guide
---
# Git Workflow Guide
A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
## Best Practices
Use branches effectively:
- [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
- implements [[Branch Strategy]] (Our standard workflow)
## Common Commands
See the [[Git Cheat Sheet]] for reference.
"""
## Best Practices
Use branches effectively:
- [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
- implements [[Branch Strategy]] (Our standard workflow)
## Common Commands
See the [[Git Cheat Sheet]] for reference.
"""
)
# Create test entity
entity, created = await entity_service.create_or_update_entity(
@@ -428,20 +433,29 @@ See the [[Git Cheat Sheet]] for reference.
file_path = file_service.get_entity_path(entity)
file_content, _ = await file_service.read_file(file_path)
# assert content is in file
assert content.strip() in file_content
# assert file
# note the permalink value is corrected
expected = dedent("""
---
title: Git Workflow Guide
type: test
permalink: test/git-workflow-guide
---
# Git Workflow Guide
A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
## Best Practices
Use branches effectively:
- [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
- implements [[Branch Strategy]] (Our standard workflow)
## Common Commands
See the [[Git Cheat Sheet]] for reference.
# assert frontmatter
assert (
"""
---
title: Git Workflow Guide
type: test
permalink: test/git-workflow-guide
---
""".strip()
in file_content
)
""").strip()
assert expected == file_content
@pytest.mark.asyncio
@@ -469,21 +483,43 @@ async def test_update_with_content(entity_service: EntityService, file_service:
file_content, _ = await file_service.read_file(file_path)
# assert content is in file
assert content.strip() in file_content
assert (
dedent(
"""
---
title: Git Workflow Guide
type: test
permalink: test/git-workflow-guide
---
# Git Workflow Guide
"""
).strip()
== file_content
)
# now update the content
update_content = """# Git Workflow Guide
A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
## Best Practices
Use branches effectively:
- [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
- implements [[Branch Strategy]] (Our standard workflow)
## Common Commands
See the [[Git Cheat Sheet]] for reference.
"""
update_content = dedent(
"""
---
title: Git Workflow Guide
type: test
permalink: test/git-workflow-guide
---
# Git Workflow Guide
A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
## Best Practices
Use branches effectively:
- [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
- implements [[Branch Strategy]] (Our standard workflow)
## Common Commands
See the [[Git Cheat Sheet]] for reference.
"""
).strip()
# Create test entity
entity, created = await entity_service.create_or_update_entity(
@@ -520,4 +556,4 @@ See the [[Git Cheat Sheet]] for reference.
file_content, _ = await file_service.read_file(file_path)
# assert content is in file
assert update_content.strip() in file_content
assert update_content.strip() == file_content
+34 -34
View File
@@ -44,7 +44,7 @@ type: knowledge
await create_test_file(project_dir / "source.md", source_content)
# Initial sync - should create forward reference
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify forward reference
source = await entity_service.get_by_permalink("source")
@@ -63,7 +63,7 @@ Target content
await create_test_file(project_dir / "target_doc.md", target_content)
# Sync again - should resolve the reference
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify reference is now resolved
source = await entity_service.get_by_permalink("source")
@@ -116,7 +116,7 @@ A test concept.
await entity_service.repository.add(other)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify results
entities = await entity_service.repository.find_all()
@@ -146,7 +146,7 @@ async def test_sync_hidden_file(
await create_test_file(project_dir / "concept/.hidden.md", "hidden")
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify results
entities = await entity_service.repository.find_all()
@@ -180,7 +180,7 @@ modified: 2024-01-01
await create_test_file(project_dir / "concept/depends_on_future.md", content)
# Sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify entity created but no relations
entity = await sync_service.entity_service.repository.get_by_permalink(
@@ -236,7 +236,7 @@ modified: 2024-01-01
await create_test_file(project_dir / "concept/entity_b.md", content_b)
# Sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify both entities and their relations
entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a")
@@ -307,7 +307,7 @@ modified: 2024-01-01
await create_test_file(project_dir / "concept/duplicate_relations.md", content)
# Sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify duplicates are handled
entity = await sync_service.entity_service.repository.get_by_permalink(
@@ -349,7 +349,7 @@ modified: 2024-01-01
await create_test_file(project_dir / "concept/invalid_category.md", content)
# Sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify observations
entity = await sync_service.entity_service.repository.get_by_permalink(
@@ -429,7 +429,7 @@ modified: 2024-01-01
await create_test_file(project_dir / f"concept/entity_{name}.md", content)
# Sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify all relations are created correctly regardless of order
entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a")
@@ -449,7 +449,7 @@ modified: 2024-01-01
@pytest.mark.asyncio
async def test_sync_empty_directories(sync_service: SyncService, test_config: ProjectConfig):
"""Test syncing empty directories."""
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Should not raise exceptions for empty dirs
assert (test_config.home).exists()
@@ -484,7 +484,7 @@ modified: 2024-01-01
doc_path.write_text("Modified during sync")
# Run sync and modification concurrently
await asyncio.gather(sync_service.sync(test_config.home), modify_file())
await asyncio.gather(sync_service.sync(test_config.home, show_progress=False), modify_file())
# Verify final state
doc = await sync_service.entity_service.repository.get_by_permalink("changing")
@@ -492,7 +492,7 @@ modified: 2024-01-01
# if we failed in the middle of a sync, the next one should fix it.
if doc.checksum is None:
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
doc = await sync_service.entity_service.repository.get_by_permalink("changing")
assert doc.checksum is not None
@@ -528,7 +528,7 @@ Testing permalink generation.
await create_test_file(test_config.home / filename, content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify permalinks
entities = await entity_service.repository.find_all()
@@ -599,7 +599,7 @@ Testing file timestamps
await create_test_file(file_path, file_dates_content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check explicit frontmatter dates
explicit_entity = await entity_service.get_by_permalink("explicit-dates")
@@ -639,7 +639,7 @@ Content for move test
await create_test_file(old_path, content)
# Initial sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Move the file
new_path = project_dir / "new" / "moved_file.md"
@@ -647,7 +647,7 @@ Content for move test
old_path.rename(new_path)
# Sync again
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check search index has updated path
results = await search_service.search(SearchQuery(text="Content for move test"))
@@ -689,7 +689,7 @@ modified: 2024-01-01
await create_test_file(test_config.home / "concept/incomplete.md", content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify entity was properly synced
updated = await entity_service.get_by_permalink("concept/incomplete")
@@ -718,7 +718,7 @@ Content for move test
await create_test_file(old_path, content)
# Initial sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Move the file
new_path = project_dir / "new" / "moved_file.md"
@@ -726,7 +726,7 @@ Content for move test
old_path.rename(new_path)
# Sync again
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
file_content, _ = await file_service.read_file(new_path)
assert "permalink: old/test-move" in file_content
@@ -745,7 +745,7 @@ Content for move test
await create_test_file(old_path, content)
# Sync new file
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# assert permalink is unique
file_content, _ = await file_service.read_file(old_path)
@@ -767,7 +767,7 @@ async def test_sync_permalink_resolved_on_update(
await create_test_file(two_file)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check permalinks
file_one_content, _ = await file_service.read_file(one_file)
@@ -790,7 +790,7 @@ test content
two_file.write_text(updated_content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check permalinks
file_two_content, _ = await file_service.read_file(two_file)
@@ -811,7 +811,7 @@ test content
await create_test_file(new_file, new_content)
# Run another time
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Should have deduplicated permalink
new_file_content, _ = await file_service.read_file(new_file)
@@ -843,7 +843,7 @@ test content
await create_test_file(note_file, content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check permalinks
file_one_content, _ = await file_service.read_file(note_file)
@@ -866,7 +866,7 @@ test content
@pytest.mark.asyncio
async def test_sync_non_markdown_files(sync_service, test_config, test_files):
"""Test syncing non-markdown files."""
report = await sync_service.sync(test_config.home)
report = await sync_service.sync(test_config.home, show_progress=False)
assert report.total == 2
# Check files were detected
@@ -889,7 +889,7 @@ async def test_sync_non_markdown_files_modified(
sync_service, test_config, test_files, file_service
):
"""Test syncing non-markdown files."""
report = await sync_service.sync(test_config.home)
report = await sync_service.sync(test_config.home, show_progress=False)
assert report.total == 2
# Check files were detected
@@ -899,7 +899,7 @@ async def test_sync_non_markdown_files_modified(
test_files["pdf"].write_text("New content")
test_files["image"].write_text("New content")
report = await sync_service.sync(test_config.home)
report = await sync_service.sync(test_config.home, show_progress=False)
assert len(report.modified) == 2
pdf_file_content, pdf_checksum = await file_service.read_file(test_files["pdf"].name)
@@ -917,7 +917,7 @@ async def test_sync_non_markdown_files_modified(
@pytest.mark.asyncio
async def test_sync_non_markdown_files_move(sync_service, test_config, test_files):
"""Test syncing non-markdown files updates permalink"""
report = await sync_service.sync(test_config.home)
report = await sync_service.sync(test_config.home, show_progress=False)
assert report.total == 2
# Check files were detected
@@ -925,7 +925,7 @@ async def test_sync_non_markdown_files_move(sync_service, test_config, test_file
assert test_files["image"].name in [f for f in report.new]
test_files["pdf"].rename(test_config.home / "moved_pdf.pdf")
report2 = await sync_service.sync(test_config.home)
report2 = await sync_service.sync(test_config.home, show_progress=False)
assert len(report2.moves) == 1
# Verify entity is updated
@@ -937,7 +937,7 @@ async def test_sync_non_markdown_files_move(sync_service, test_config, test_file
@pytest.mark.asyncio
async def test_sync_non_markdown_files_deleted(sync_service, test_config, test_files):
"""Test syncing non-markdown files updates permalink"""
report = await sync_service.sync(test_config.home)
report = await sync_service.sync(test_config.home, show_progress=False)
assert report.total == 2
# Check files were detected
@@ -945,7 +945,7 @@ async def test_sync_non_markdown_files_deleted(sync_service, test_config, test_f
assert test_files["image"].name in [f for f in report.new]
test_files["pdf"].unlink()
report2 = await sync_service.sync(test_config.home)
report2 = await sync_service.sync(test_config.home, show_progress=False)
assert len(report2.deleted) == 1
# Verify entity is deleted
@@ -964,14 +964,14 @@ async def test_sync_non_markdown_files_move_with_delete(
await create_test_file(test_config.home / "other/doc-1.pdf", "content2")
# Initial sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# First move/delete the original file to make way for the move
(test_config.home / "doc.pdf").unlink()
(test_config.home / "other/doc-1.pdf").rename(test_config.home / "doc.pdf")
# Sync again
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify the changes
moved_entity = await sync_service.entity_repository.get_by_file_path("doc.pdf")
@@ -1003,7 +1003,7 @@ tags: []
await create_test_file(note_file, content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Check permalinks
file_one_content, _ = await file_service.read_file(note_file)
+75 -6
View File
@@ -73,9 +73,13 @@ async def test_handle_file_add(watch_service, test_config):
"""Test handling new file creation."""
project_dir = test_config.home
# empty dir is ignored
empty_dir = project_dir / "empty_dir"
empty_dir.mkdir()
# Setup changes
new_file = project_dir / "new_note.md"
changes = {(Change.added, str(new_file))}
changes = {(Change.added, str(empty_dir)), (Change.added, str(new_file))}
# Create the file
content = """---
@@ -106,6 +110,10 @@ async def test_handle_file_modify(watch_service, test_config):
"""Test handling file modifications."""
project_dir = test_config.home
# empty dir is ignored
empty_dir = project_dir / "empty_dir"
empty_dir.mkdir()
# Create initial file
test_file = project_dir / "test_note.md"
initial_content = """---
@@ -117,7 +125,7 @@ Initial content
await create_test_file(test_file, initial_content)
# Initial sync
await watch_service.sync_service.sync(project_dir)
await watch_service.sync_service.sync(project_dir, show_progress=False)
# Modify file
modified_content = """---
@@ -129,7 +137,7 @@ Modified content
await create_test_file(test_file, modified_content)
# Setup changes
changes = {(Change.modified, str(test_file))}
changes = {(Change.modified, str(empty_dir)), (Change.modified, str(test_file))}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
@@ -161,7 +169,7 @@ Test content
await create_test_file(test_file, content)
# Initial sync
await watch_service.sync_service.sync(project_dir)
await watch_service.sync_service.sync(project_dir, show_progress=False)
# Delete file
test_file.unlink()
@@ -199,7 +207,7 @@ Test content
await create_test_file(old_path, content)
# Initial sync
await watch_service.sync_service.sync(project_dir)
await watch_service.sync_service.sync(project_dir, show_progress=False)
initial_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"old/test_move.md"
)
@@ -298,7 +306,7 @@ type: knowledge
Test content for rapid moves
"""
await create_test_file(original_path, content)
await watch_service.sync_service.sync(project_dir)
await watch_service.sync_service.sync(project_dir, show_progress=False)
# Perform rapid moves
temp_path = project_dir / "temp.md"
@@ -361,3 +369,64 @@ Test content for rapid moves
"original.md"
)
assert original_entity is None # delete event is handled
@pytest.mark.asyncio
async def test_handle_directory_rename(watch_service, test_config):
"""Test handling directory rename operations - regression test for the bug where directories
were being processed as files, causing errors."""
from unittest.mock import AsyncMock
project_dir = test_config.home
# Create a directory with a file inside
old_dir_path = project_dir / "old_dir"
old_dir_path.mkdir(parents=True, exist_ok=True)
file_in_dir = old_dir_path / "test_file.md"
content = """---
type: knowledge
---
# Test File
This is a test file in a directory
"""
await create_test_file(file_in_dir, content)
# Initial sync to add the file to the database
await watch_service.sync_service.sync(project_dir, show_progress=False)
# Rename the directory
new_dir_path = project_dir / "new_dir"
old_dir_path.rename(new_dir_path)
# Setup changes that simulate directory rename
# When a directory is renamed, watchfiles reports it as deleted and added
changes = {
(Change.deleted, str(old_dir_path)),
(Change.added, str(new_dir_path)),
}
# Create a mocked version of sync_file to track calls
original_sync_file = watch_service.sync_service.sync_file
mock_sync_file = AsyncMock(side_effect=original_sync_file)
watch_service.sync_service.sync_file = mock_sync_file
# Handle changes - this should not throw an exception
await watch_service.handle_changes(project_dir, changes)
# Check if our mock was called with any directory paths
for call in mock_sync_file.call_args_list:
args, kwargs = call
path = args[0]
full_path = project_dir / path
assert not full_path.is_dir(), f"sync_file should not be called with directory path: {path}"
# The file path should be untouched since we're ignoring directory events
# We'd need a separate event for the file itself to be updated
old_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"old_dir/test_file.md"
)
# The original entity should still exist since we only renamed the directory
# but didn't process updates to the file itself
assert old_entity is not None
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the Basic Memory configuration system."""
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from basic_memory.config import BasicMemoryConfig, ConfigManager, DATA_DIR_NAME, CONFIG_FILE_NAME
class TestBasicMemoryConfig:
"""Test the BasicMemoryConfig pydantic model."""
def test_default_values(self):
"""Test that default values are set correctly."""
config = BasicMemoryConfig()
assert "main" in config.projects
assert config.default_project == "main"
def test_model_post_init(self):
"""Test that model_post_init ensures valid configuration."""
# Test with empty projects
config = BasicMemoryConfig(projects={}, default_project="nonexistent")
assert "main" in config.projects
assert config.default_project == "main"
# Test with invalid default project
config = BasicMemoryConfig(
projects={"project1": "/path/to/project1"}, default_project="nonexistent"
)
assert "main" in config.projects
assert config.default_project == "main"
def test_custom_values(self):
"""Test with custom values."""
config = BasicMemoryConfig(
projects={"project1": "/path/to/project1"}, default_project="project1"
)
assert config.projects["project1"] == "/path/to/project1"
assert config.default_project == "project1"
# Main should still be added automatically
assert "main" in config.projects
class TestConfigManager:
"""Test the ConfigManager class."""
@pytest.fixture
def temp_home(self, monkeypatch):
"""Create a temporary directory for testing."""
with TemporaryDirectory() as tempdir:
temp_home = Path(tempdir)
monkeypatch.setattr(Path, "home", lambda: temp_home)
yield temp_home
def test_init_creates_config_dir(self, temp_home):
"""Test that init creates the config directory."""
config_manager = ConfigManager()
assert config_manager.config_dir.exists()
assert config_manager.config_dir == temp_home / ".basic-memory"
def test_init_creates_default_config(self, temp_home):
"""Test that init creates a default config if none exists."""
config_manager = ConfigManager()
assert config_manager.config_file.exists()
assert "main" in config_manager.projects
assert config_manager.default_project == "main"
def test_save_and_load_config(self, temp_home):
"""Test saving and loading configuration."""
config_manager = ConfigManager()
# Add a project
config_manager.add_project("test", str(temp_home / "test-project"))
# Set as default
config_manager.set_default_project("test")
# Create a new manager to load from file
new_manager = ConfigManager()
assert "test" in new_manager.projects
assert new_manager.default_project == "test"
assert Path(new_manager.projects["test"]) == temp_home / "test-project"
def test_get_project_path(self, temp_home):
"""Test getting a project path."""
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Get by name
path = config_manager.get_project_path("test")
assert path == temp_home / "test-project"
# Get default
path = config_manager.get_project_path()
assert path == temp_home / "basic-memory"
# Project does not exist
with pytest.raises(ValueError):
config_manager.get_project_path("nonexistent")
def test_environment_variable(self, temp_home, monkeypatch):
"""Test using environment variable to select project."""
config_manager = ConfigManager()
config_manager.add_project("env_test", str(temp_home / "env-test-project"))
# Set environment variable
monkeypatch.setenv("BASIC_MEMORY_PROJECT", "env_test")
# Get project without specifying name
path = config_manager.get_project_path()
assert path == temp_home / "env-test-project"
def test_remove_project(self, temp_home):
"""Test removing a project."""
config_manager = ConfigManager()
config_manager.add_project("test", str(temp_home / "test-project"))
# Remove project
config_manager.remove_project("test")
assert "test" not in config_manager.projects
# Cannot remove default project
with pytest.raises(ValueError):
config_manager.remove_project("main")
# Cannot remove nonexistent project
with pytest.raises(ValueError):
config_manager.remove_project("nonexistent")
def test_load_invalid_config(self, temp_home):
"""Test loading invalid configuration."""
# Create invalid config file
config_dir = temp_home / DATA_DIR_NAME
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / CONFIG_FILE_NAME
config_file.write_text("invalid json")
# Load config
config_manager = ConfigManager()
# Should have default config
assert "main" in config_manager.projects
assert config_manager.default_project == "main"
def test_save_config_error(self, temp_home, monkeypatch):
"""Test error when saving configuration."""
# Create config manager
config_manager = ConfigManager()
# Make write_text raise an exception
def mock_write_text(content):
raise PermissionError("Permission denied")
monkeypatch.setattr(Path, "write_text", mock_write_text)
# Should not raise exception
config_manager.save_config(config_manager.config)
+1 -1
View File
@@ -57,7 +57,7 @@ Testing permalink generation.
await create_test_file(project_dir / filename, content)
# Run sync
await sync_service.sync(test_config.home)
await sync_service.sync(test_config.home, show_progress=False)
# Verify permalinks
for filename, expected_permalink in test_cases: