mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user