Compare commits

..

21 Commits

Author SHA1 Message Date
semantic-release 4bcbeacdd2 chore(release): 0.8.0 [skip ci] 2025-02-28 02:50:27 +00:00
Paul Hernandez 093dab5f03 feat: Add enhanced prompts and resources (#15)
## Summary
- Add comprehensive documentation to all MCP prompt modules
- Enhance search prompt with detailed contextual output formatting
- Implement consistent logging and docstring patterns across prompt
utilities
- Fix type checking in prompt modules

## Prompts Added/Enhanced
- `search.py`: New formatted output with relevance scores, excerpts, and
next steps
- `recent_activity.py`: Enhanced with better metadata handling and
documentation
- `continue_conversation.py`: Improved context management

## Resources Added/Enhanced
- `ai_assistant_guide`: Resource with description to give to LLM to
understand how to use the tools

## Technical improvements
- Added detailed docstrings to all prompt modules explaining their
purpose and usage
- Enhanced the search prompt with rich contextual output that helps LLMs
understand results
- Created a consistent pattern for formatting output across prompts
- Improved error handling in metadata extraction
- Standardized import organization and naming conventions
- Fixed various type checking issues across the codebase

This PR is part of our ongoing effort to improve the MCP's interaction
quality with LLMs, making the system more helpful and intuitive for AI
assistants to navigate knowledge bases.

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-27 20:48:56 -06:00
Paul Hernandez 0d7b0b3d7e feat: Add new canvas tool to create json canvas files in obsidian. (#14)
Add new `canvas` tool to create json canvas files in obsidian.

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-25 21:21:05 -06:00
phernandez 93cc6379eb chore: formatting 2025-02-24 22:18:27 -06:00
Paul Hernandez 37a01b806d feat: Incremental sync on watch (#13)
- incremental sync on watch
- sync non-markdown files in knowledge base
- experimental `read_resource` tool for reading non-markdown files in raw form (pdf, image)
2025-02-24 22:13:15 -06:00
phernandez a573f78317 try to optimize image size for mcp tool response 2025-02-23 17:30:47 -06:00
phernandez ab36e7e560 return optimized image from read_resource tool - tweaks 2025-02-23 16:54:07 -06:00
phernandez 959b278687 return optimized image from read_resource tool 2025-02-23 16:50:00 -06:00
phernandez 04b387c742 add more logging for image response 2025-02-23 16:38:20 -06:00
phernandez 0c59fd5e83 handle image response in tools WIP 2025-02-23 16:14:06 -06:00
phernandez 2ed920aa50 add resource tool to read binary files 2025-02-23 15:34:41 -06:00
phernandez bd20185492 read img/pdf via tool 2025-02-23 13:51:47 -06:00
phernandez f731a23e49 fix sync and status cli 2025-02-22 17:16:33 -06:00
phernandez 07a9415451 add alembic migration for relation_to_name uniqueness 2025-02-22 15:05:40 -06:00
phernandez 20d0375ffa sync non-markdown files 2025-02-22 14:50:40 -06:00
phernandez eb4a55a5e7 code cleanup 2025-02-21 22:11:54 -06:00
phernandez 51b4eb32c5 fix tests 2025-02-21 21:40:28 -06:00
phernandez 74adae506e fix tests 2025-02-21 21:36:39 -06:00
phernandez 94394f0bfe new sync service implementation 2025-02-21 19:10:23 -06:00
phernandez 9e3f71cb87 file sync WIP 2025-02-20 22:29:40 -06:00
phernandez f4b703e57f chore: refactor logging setup 2025-02-19 20:22:01 -06:00
80 changed files with 4640 additions and 1248 deletions
+59
View File
@@ -1,6 +1,65 @@
# CHANGELOG
## v0.8.0 (2025-02-28)
### Chores
- Formatting
([`93cc637`](https://github.com/basicmachines-co/basic-memory/commit/93cc6379ebb9ecc6a1652feeeecbf47fc992d478))
- Refactor logging setup
([`f4b703e`](https://github.com/basicmachines-co/basic-memory/commit/f4b703e57f0ddf686de6840ff346b8be2be499ad))
### Features
- Add enhanced prompts and resources
([#15](https://github.com/basicmachines-co/basic-memory/pull/15),
[`093dab5`](https://github.com/basicmachines-co/basic-memory/commit/093dab5f03cf7b090a9f4003c55507859bf355b0))
## Summary - Add comprehensive documentation to all MCP prompt modules - Enhance search prompt with
detailed contextual output formatting - Implement consistent logging and docstring patterns across
prompt utilities - Fix type checking in prompt modules
## Prompts Added/Enhanced - `search.py`: New formatted output with relevance scores, excerpts, and
next steps - `recent_activity.py`: Enhanced with better metadata handling and documentation -
`continue_conversation.py`: Improved context management
## Resources Added/Enhanced - `ai_assistant_guide`: Resource with description to give to LLM to
understand how to use the tools
## Technical improvements - Added detailed docstrings to all prompt modules explaining their purpose
and usage - Enhanced the search prompt with rich contextual output that helps LLMs understand
results - Created a consistent pattern for formatting output across prompts - Improved error
handling in metadata extraction - Standardized import organization and naming conventions - Fixed
various type checking issues across the codebase
This PR is part of our ongoing effort to improve the MCP's interaction quality with LLMs, making the
system more helpful and intuitive for AI assistants to navigate knowledge bases.
🤖 Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
- Add new `canvas` tool to create json canvas files in obsidian.
([#14](https://github.com/basicmachines-co/basic-memory/pull/14),
[`0d7b0b3`](https://github.com/basicmachines-co/basic-memory/commit/0d7b0b3d7ede7555450ddc9728951d4b1edbbb80))
Add new `canvas` tool to create json canvas files in obsidian.
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
- Incremental sync on watch ([#13](https://github.com/basicmachines-co/basic-memory/pull/13),
[`37a01b8`](https://github.com/basicmachines-co/basic-memory/commit/37a01b806d0758029d34a862e76d44c7e5d538a5))
- incremental sync on watch - sync non-markdown files in knowledge base - experimental
`read_resource` tool for reading non-markdown files in raw form (pdf, image)
## v0.7.0 (2025-02-19)
### Bug Fixes
+465
View File
@@ -0,0 +1,465 @@
# CLAUDE.md - Basic Memory Project Guide
## Project Overview
Basic Memory is a local-first knowledge management system built on the Model Context Protocol (MCP). It enables
bidirectional communication between LLMs (like Claude) and markdown files, creating a personal knowledge graph that can
be traversed using memory:// URLs.
## CODEBASE DEVELOPMENT
### Build and Test Commands
- Install: `make install` or `pip install -e ".[dev]"`
- Run tests: `uv run pytest -p pytest_mock -v` or `make test`
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
- Lint: `make lint` or `ruff check . --fix`
- Type check: `make type-check` or `uv run pyright`
- Format: `make format` or `uv run ruff format .`
- Run checks: `make check` (runs lint, format, type-check, test)
- Create migration: `make migration m="Your migration message"`
- Run development MCP server: `uv run mcp dev src/basic_memory/mcp/main.py`
### Code Style Guidelines
- Line length: 100 characters max
- Python 3.12+ with full type annotations
- Format with ruff (consistent styling)
- Import order: standard lib, third-party, local imports
- Naming: snake_case for functions/variables, PascalCase for classes
- Prefer async patterns with SQLAlchemy 2.0
- Use Pydantic v2 for data validation and schemas
- CLI uses Typer for command structure
- API uses FastAPI for endpoints
- Use dedicated exceptions from services/exceptions.py
- Follow the repository pattern for data access
### Codebase Architecture
- `/api` - FastAPI implementation of REST endpoints
- `/cli` - Typer command-line interface
- `/mcp` - Model Context Protocol server implementation
- `/models` - SQLAlchemy ORM models
- `/repository` - Data access layer
- `/schemas` - Pydantic models for validation
- `/services` - Business logic layer
- `/sync` - File synchronization services
- `/markdown` - Markdown parsing and processing
### Development Notes
- MCP tools are defined in src/basic_memory/mcp/tools/
- MCP prompts are defined in src/basic_memory/mcp/prompts/
- Schema changes require Alembic migrations
- SQLite is used for indexing, files are source of truth
- Testing uses pytest with asyncio support (strict mode)
- Test database uses in-memory SQLite
- MCP tools should be atomic, composable operations
- Use `textwrap.dedent()` for multi-line string formatting in prompts and tools
- Prompts are special types of tools that format content for user consumption
## BASIC MEMORY PRODUCT USAGE
### Knowledge Structure
- Entity: Any concept, document, or idea represented as a markdown file
- Observation: A categorized fact about an entity (`[category] content`)
- Relation: A directional link between entities (`relation_type [[Target]]`)
- Frontmatter: YAML metadata at the top of markdown files
- Knowledge representation follows precise markdown format:
- Observations with [category] prefixes
- Relations with WikiLinks [[Entity]]
- Frontmatter with metadata
### Basic Memory Commands
- Sync knowledge: `basic-memory sync` or `basic-memory sync --watch`
- Import from Claude: `basic-memory import claude conversations`
- Import from ChatGPT: `basic-memory import chatgpt`
- Import from JSON: `basic-memory import memory-json`
- Check status: `basic-memory status`
- Tool access: `basic-memory tools` (provides CLI access to MCP tools)
- Guide: `basic-memory tools basic-memory-guide`
- Continue: `basic-memory tools continue-conversation --topic="search"`
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
- `write_note()` - Create/update markdown notes
- `read_note()` - Read existing notes
- `build_context()` - Navigate the knowledge graph via memory:// URLs
- `search()` - Query the knowledge base
- `recent_activity()` - Get recently updated information
- `canvas()` - Generate JSON canvas files for Obsidian
- MCP Prompts for better AI interaction:
- `basic_memory_guide()` - Get guidance on using Basic Memory tools
- `continue_session()` - Continue previous conversations with context
### Best Practices
- Use memory:// URLs to reference entities
- Add clear categories to observations (e.g., [idea], [decision], [requirement])
- Use descriptive relation types (e.g., implements, depends_on, contradicts)
- Maintain unique permalinks for stable entity references
- Take advantage of both manual editing and LLM-assisted knowledge creation
- Use `basic_memory_guide()` when starting new conversations to bootstrap tool knowledge
- Use `continue_session()` with topic keywords to pick up previous conversations
- Encourage Claude to proactively use tools by providing clear instructions
---
id: process/ai-code-flow.md
created: '2025-01-03T22:11:42.803071+00:00'
modified: '2025-01-03T22:11:42.803071+00:00'
permalink: process/ai-code-flow
---
## AI-Human Collaborative Development: A New Model
What makes Basic Memory unique isn't just its technical architecture - it emerged from and enables a new kind of
development process. While many use AI for code generation or problem-solving, we've discovered something more powerful:
true collaborative development between humans and AI.
### The Basic Memory Development Story
Our own development process demonstrates this:
1. AI (Claude) writes initial implementation
2. Human (Paul) reviews, runs, and commits code
3. Knowledge persists across conversations
4. Development continues seamlessly even across different AI instances
5. Results improve through iterative collaboration
```mermaid
graph TD
subgraph "Human Activities"
Review[Code Review]
Test[Run Tests]
Commit[Git Commit]
Plan[Strategic Planning]
end
subgraph "AI Activities"
Code[Write Code]
Design[Architecture Design]
Debug[Problem Solving]
Doc[Documentation]
end
subgraph "Shared Knowledge"
KB[Knowledge Base]
Context[Conversation Context]
History[Development History]
end
Code --> Review
Review --> Test
Test --> Commit
KB --> Code
KB --> Design
Context --> Debug
Review --> KB
Commit --> History
Plan --> Context
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef shared fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class KB, Context, History shared
```
### Beyond "AI Tools"
This isn't just about using AI to generate code. It's about:
- True collaborative development
- Persistent knowledge across sessions
- Seamless context switching between AI instances
- Iterative improvement through shared understanding
- Building complex systems through sustained collaboration
### The Multiplier Effect
Having an AI collaborator who:
- Remembers all technical discussions
- Can reference any previous decision
- Writes consistent, well-documented code
- Maintains context across sessions
- Works at human speed but with machine precision
It's like having a team of senior developers who:
- Never forget project details
- Always write clear documentation
- Maintain perfect consistency
- Are available 24/7
- Learn and adapt from every interaction
### Key Innovation
The breakthrough is turning automated assistance into true collaboration:
- AI isn't just a tool, but a development partner
- Knowledge builds naturally through use
- Context persists across all interactions
- Work continues seamlessly across sessions
- Development becomes truly collaborative
This approach has implications far beyond just our project - it's a new model for how humans and AI can work together to
build complex systems.
## AI-Human Collaboration: Lessons from Basic Memory
### Technical Breakthroughs
#### Session Management Evolution
```mermaid
graph TD
S1[Session Start] -->|Load Context| KG[Knowledge Graph]
KG -->|Build Context| AI[AI Understanding]
AI -->|Collaborate| H[Human Review]
H -->|Commit Changes| Git
Git -->|New Session| S2[Session Resume]
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
```
#### File Collaboration Pattern
```mermaid
graph TD
H1[Human] -->|1 . Update & Commit| Git
Git -->|2 . Read File| AI
AI -->|3 . Write Changes| File
File -->|4 . Review in IDE| H2[Human]
subgraph "Synchronization"
Git
File
end
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef sync fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class Git, File sync
```
### Productivity Transformation
#### Development Timeline Comparison
```mermaid
graph LR
subgraph "Solo Development"
S1[basic-foundation] -->|6 months| S2[Completion]
end
subgraph "Collaborative Development"
C1[basic-memory] -->|Rapid Progress| C2[basic-factory]
C2 -->|Continuous Evolution| C3[Future Projects]
end
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
```
### Key Learnings
1. **Technical Process Innovation**
- Discovered effective file collaboration patterns
- Mastered MCP server interface together
- Developed robust session management
- Created reliable git-based workflow
2. **Expanded Possibility Space**
- Projects previously considered too complex become achievable
- Rapid iteration on complex technical concepts
- Broader exploration of solution spaces
- Confidence to tackle ambitious challenges
3. **Motivation and Momentum**
- No more solo debugging sessions
- Shared problem-solving reduces cognitive load
- Continuous progress maintains motivation
- Complex learning curves become collaborative adventures
4. **Knowledge Management**
- Git commits capture decision points
- Conversations document rationale
- Code reviews become learning opportunities
- Shared context builds over time
### The "10x Developer" Truth
It's not about having an AI that makes you 10x faster - it's about:
- Never facing a blank editor alone
- Always having a thought partner
- Reducing decision fatigue
- Maintaining momentum through challenges
- Building shared knowledge over time
### Real Examples from Our Work
#### Session Management Evolution
```python
# Before: Opaque MCP server interface
server = MCPServer()
server.handle_request(...)
# After: Clear context management
class MemoryServer(MCPServer):
def __init__(self, project_config):
self.memory_service = MemoryService(project_config)
async def handle_create_entities(self, request):
context = await self.memory_service.load_context(
request.project,
include_relations=True
)
# Collaborative magic happens here
```
#### File Collaboration
```markdown
# Memory Service Discussion (Chat Log)
Claude: Here's the updated memory service implementation...
Human: Looks good! I'll commit and we can iterate.
Claude: Reading latest version from git...
Human: Want to add relation support?
Claude: Analyzing current implementation...
```
### Impact on Development Culture
What we've discovered is more than a technical process - it's a new way of thinking about development:
1. **From Solo to Collaborative**
- Traditional: Developer alone with problems
- New: Continuous collaborative problem-solving
2. **From Linear to Exploratory**
- Traditional: Constrained by individual knowledge
- New: Free to explore broader solution spaces
3. **From Draining to Energizing**
- Traditional: High cognitive load
- New: Shared intellectual adventure
```mermaid
graph TD
C1[Chat: Initial Design] -->|leads_to| D1{Design Decision}
C2[Chat: Implementation] -->|references| D1
C2 -->|results_in| Code[Code Change]
D1 -->|influences| Code
Code -->|implements| Concept{Semantic Web}
Test[Test Suite] -->|validates| Code
Doc[Documentation] -->|describes| Code
D1 -.->|captured_in| Basic[Basic Memory]
Code -.->|tracked_in| Basic
Test -.->|stored_in| Basic
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef decision fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef system fill: #404040, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class D1 decision
class Basic system
class Concept decision
```
### Future Implications
This model of human-AI collaboration suggests:
1. More ambitious projects become accessible
2. Learning curves become less daunting
3. Development becomes more enjoyable
4. Complex systems can be built more reliably
The real breakthrough isn't just the technical achievements, but discovering how to make complex development sustainable
and enjoyable through true collaboration.
## Beyond Code Generation: A New Development Paradigm
What we've discovered through building Basic Memory isn't just a knowledge management system - it's a new way of
thinking about human-AI collaboration. This isn't about AI completing your code or suggesting functions. It's about true
intellectual partnership.
### From Tools to Partners
```mermaid
graph TD
subgraph "Traditional AI Tools"
AC[Autocomplete]
CG[Code Generation]
SR[Syntax Review]
end
subgraph "Collaborative Development"
TP[Thought Partnership]
PS[Problem Solving]
AD[Architecture Design]
KS[Knowledge Synthesis]
end
subgraph "Outcomes"
BI[Bigger Ideas]
CP[Complex Projects]
KB[Knowledge Building]
MI[More Innovation]
end
TP --> BI
PS --> CP
AD --> MI
KS --> KB
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef outcomes fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class BI, CP, KB, MI outcomes
```
### The Power of Partnership
Through our own development journey, we've discovered that true AI collaboration means:
1. **Expanded Thinking Space**
- Explore more possibilities
- Challenge assumptions
- Combine different perspectives
- Take on bigger challenges
2. **Continuous Momentum**
- Never face complex problems alone
- Maintain enthusiasm through challenges
- Turn obstacles into opportunities
- Keep projects moving forward
3. **Knowledge Amplification**
- Build on every interaction
- Capture insights automatically
- Learn from each decision
- Grow shared understanding
### Beyond Code Generation
This new paradigm transforms development from:
- Solo problem-solving → Collaborative exploration
- Limited perspective → Multiple viewpoints
- Linear progress → Parallel innovation
- Isolated knowledge → Shared understanding
### Real Impact
What makes this transformative:
- Projects that seemed too ambitious become achievable
- Complex problems become engaging challenges
- Learning curves become collaborative adventures
- Development becomes a shared journey of discovery
The result isn't just better code - it's better thinking, more ambitious projects, and a more enjoyable development
process.
This is the future of development: not AI replacing developers, but empowering them to think bigger, work smarter, and
build more amazing things together.
+12 -3
View File
@@ -27,7 +27,7 @@ format:
uv run ruff format .
# run inspector tool
run-dev:
run-inspector:
uv run mcp dev src/basic_memory/mcp/main.py
# Build app installer
@@ -40,6 +40,15 @@ installer-win:
update-deps:
uv lock f--upgrade
uv lock --upgrade
check: lint format type-check test
check: lint format type-check test
# Target for generating Alembic migrations with a message from command line
migration:
@if [ -z "$(m)" ]; then \
echo "Usage: make migration m=\"Your migration message\""; \
exit 1; \
fi; \
cd src/basic_memory/alembic && alembic revision --autogenerate -m "$(m)"
+275
View File
@@ -0,0 +1,275 @@
# AI Assistant Guide
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).
## 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.
- 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
## Core Tools
Basic Memory provides several tools through the MCP (Model Context Protocol) for LLMs:
```python
# Writing knowledge
response = await write_note(
title="Search Design",
content=content,
folder="specs",
tags=["search", "design"],
verbose=True # 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
# Building context
context = await build_context("memory://specs/search")
# Following relations
impl = await build_context("memory://specs/search/implements/*")
# Checking changes
activity = await recent_activity(timeframe="1 week")
# Creating a json canvas diagram
activity = await canvas(...)
```
## Semantic Markup in Plain Text
Knowledge is encoded within standard markdown using semantic conventions that are both human-readable and
machine-processable.
**Key aspects:**
- 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
**Examples:**
- Observation syntax: `- [category] Content text #tag1 #tag2 (optional context)`
- Relation syntax: `- relation_type [[Entity]] (optional context)`
- Inline relations through `[[Entity]]` Wiki Link style references
## Knowledge Graph Through Relations
Connections between documents create a knowledge graph without requiring a specialized database.
**Key aspects:**
- Relations create edges between document nodes
- Relation types provide semantic meaning to connections
- Navigation between knowledge via relation traversal
- Emergent structure through use
**Examples:**
- `implements`, `extends`, `relates_to` relations
- Following paths like `docs/search/implements/*`
- Context building by walking the graph
## Understanding Users
Users will interact 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.
```
AI Actions:
- record note via `write_note("...")`
1. Referencing existing knowledge:
```
Human: "Take a look at memory://specs/search"
Response: Let me build context from that and related documents.
```
AI Actions:
- build context via `build_context("memory://specs/search")`
- examine results
- read content via `read_note()`
2. Finding information:
```
Human: "What were our decisions about auth?"
Response: I'll search for relevant notes and build context.
```
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
4. **Building Context**
- Start specific
- Follow relations
- Check recent changes
- Build incrementally
5. **Writing Knowledge**
- Using the same title + folder will overwrite a note
- Use semantic markup
- Create useful relations
- Keep files organized
## Common Patterns
### Capturing Discussions
```python
# Document a decision
response = await write_note(
title="Auth System Decision",
folder="decisions",
content="""# Auth System Decision
## 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]]
"""
)
```
### 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,
verbose=True
)
except:
# Create new
await write_note(
title="Search Design",
content=new_content,
)
```
## Error Handling
Common issues to watch for:
6. **Missing Content**
```python
try:
content = await read_note("Document")
except:
# Try search
results = await search({"text": "Document"})
```
7. **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
```
8. **Pattern Matching**
```python
# If pattern fails, try:
# - More specific path
# - Direct lookup
# - Search instead
# - Recent activity
```
## Best Practices
1. **Read and write Notes as needed**
- Write notes to record context
- See what was parsed
- Check relations
- Verify changes
2. **Build Context Carefully**
- Start specific
- Follow logical paths
- Combine approaches
- Stay relevant
3. **Write Clean Content**
- Clear structure
- Good organization
- Useful relations
- Regular cleanup
Built with ♥️ by Basic Machines
+115
View File
@@ -0,0 +1,115 @@
---
title: JSON Canvas Spec
version: 1.0
url: https://raw.githubusercontent.com/obsidianmd/jsoncanvas/refs/heads/main/spec/1.0.md
---
# JSON Canvas Spec
<small>Version 1.0 — 2024-03-11</small>
## Top level
The top level of JSON Canvas contains two arrays:
- `nodes` (optional, array of nodes)
- `edges` (optional, array of edges)
## Nodes
Nodes are objects within the canvas. Nodes may be text, files, links, or groups.
Nodes are placed in the array in ascending order by z-index. The first node in the array should be displayed below all
other nodes, and the last node in the array should be displayed on top of all other nodes.
### Generic node
All nodes include the following attributes:
- `id` (required, string) is a unique ID for the node.
- `type` (required, string) is the node type.
- `text`
- `file`
- `link`
- `group`
- `x` (required, integer) is the `x` position of the node in pixels.
- `y` (required, integer) is the `y` position of the node in pixels.
- `width` (required, integer) is the width of the node in pixels.
- `height` (required, integer) is the height of the node in pixels.
- `color` (optional, `canvasColor`) is the color of the node, see the Color section.
### Text type nodes
Text type nodes store text. Along with generic node attributes, text nodes include the following attribute:
- `text` (required, string) in plain text with Markdown syntax.
### File type nodes
File type nodes reference other files or attachments, such as images, videos, etc. Along with generic node attributes,
file nodes include the following attributes:
- `file` (required, string) is the path to the file within the system.
- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`.
### Link type nodes
Link type nodes reference a URL. Along with generic node attributes, link nodes include the following attribute:
- `url` (required, string)
### Group type nodes
Group type nodes are used as a visual container for nodes within it. Along with generic node attributes, group nodes
include the following attributes:
- `label` (optional, string) is a text label for the group.
- `background` (optional, string) is the path to the background image.
- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values:
- `cover` fills the entire width and height of the node.
- `ratio` maintains the aspect ratio of the background image.
- `repeat` repeats the image as a pattern in both x/y directions.
## Edges
Edges are lines that connect one node to another.
- `id` (required, string) is a unique ID for the edge.
- `fromNode` (required, string) is the node `id` where the connection starts.
- `fromSide` (optional, string) is the side where this edge starts. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified.
Valid values:
- `none`
- `arrow`
- `toNode` (required, string) is the node `id` where the connection ends.
- `toSide` (optional, string) is the side where this edge ends. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid
values:
- `none`
- `arrow`
- `color` (optional, `canvasColor`) is the color of the line, see the Color section.
- `label` (optional, string) is a text label for the edge.
## Color
The `canvasColor` type is used to encode color data for nodes and edges. Colors attributes expect a string. Colors can
be specified in hex format e.g. `"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset colors
exist, mapped to the following numbers:
- `"1"` red
- `"2"` orange
- `"3"` yellow
- `"4"` green
- `"5"` cyan
- `"6"` purple
Specific values for the preset colors are intentionally not defined so that applications can tailor the presets to their
specific brand colors or color scheme.
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.7.0"
version = "0.8.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
@@ -30,6 +30,7 @@ dependencies = [
"alembic>=1.14.1",
"qasync>=0.27.1",
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
"pillow>=11.1.0",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.7.0"
__version__ = "0.8.0"
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
+119
View File
@@ -0,0 +1,119 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = .
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+23 -1
View File
@@ -1,5 +1,6 @@
"""Alembic environment configuration."""
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
@@ -8,6 +9,10 @@ from sqlalchemy import pool
from alembic import context
from basic_memory.models import Base
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
os.environ["BASIC_MEMORY_ENV"] = "test"
from basic_memory.config import config as app_config
# this is the Alembic Config object, which provides
@@ -18,6 +23,8 @@ config = context.config
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
@@ -27,6 +34,14 @@ if config.config_file_name is not None:
target_metadata = Base.metadata
# Add this function to tell Alembic what to include/exclude
def include_object(object, name, type_, reflected, compare_to):
# Ignore SQLite FTS tables
if type_ == "table" and name.startswith("search_index"):
return False
return True
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -44,6 +59,8 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_object=include_object,
render_as_batch=True,
)
with context.begin_transaction():
@@ -63,7 +80,12 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
@@ -0,0 +1,51 @@
"""remove required from entity.permalink
Revision ID: 502b60eaa905
Revises: b3c3938bacdb
Create Date: 2025-02-24 13:33:09.790951
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "502b60eaa905"
down_revision: Union[str, None] = "b3c3938bacdb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=True)
batch_op.drop_index("ix_entity_permalink")
batch_op.create_index(batch_op.f("ix_entity_permalink"), ["permalink"], unique=False)
batch_op.drop_constraint("uix_entity_permalink", type_="unique")
batch_op.create_index(
"uix_entity_permalink",
["permalink"],
unique=True,
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.drop_index(
"uix_entity_permalink",
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
batch_op.create_unique_constraint("uix_entity_permalink", ["permalink"])
batch_op.drop_index(batch_op.f("ix_entity_permalink"))
batch_op.create_index("ix_entity_permalink", ["permalink"], unique=1)
batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""relation to_name unique index
Revision ID: b3c3938bacdb
Revises: 3dae7c7b1564
Create Date: 2025-02-22 14:59:30.668466
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b3c3938bacdb"
down_revision: Union[str, None] = "3dae7c7b1564"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite doesn't support constraint changes through ALTER
# Need to recreate table with desired constraints
with op.batch_alter_table("relation") as batch_op:
# Drop existing unique constraint
batch_op.drop_constraint("uix_relation", type_="unique")
# Add new constraints
batch_op.create_unique_constraint(
"uix_relation_from_id_to_id", ["from_id", "to_id", "relation_type"]
)
batch_op.create_unique_constraint(
"uix_relation_from_id_to_name", ["from_id", "to_name", "relation_type"]
)
def downgrade() -> None:
with op.batch_alter_table("relation") as batch_op:
# Drop new constraints
batch_op.drop_constraint("uix_relation_from_id_to_name", type_="unique")
batch_op.drop_constraint("uix_relation_from_id_to_id", type_="unique")
# Restore original constraint
batch_op.create_unique_constraint("uix_relation", ["from_id", "to_id", "relation_type"])
-4
View File
@@ -7,18 +7,14 @@ from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from loguru import logger
import basic_memory
from basic_memory import db
from basic_memory.config import config as app_config
from basic_memory.api.routers import knowledge, search, memory, resource
from basic_memory.utils import setup_logging
@asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover
"""Lifecycle manager for the FastAPI app."""
setup_logging(log_file=".basic-memory/basic-memory.log")
logger.info(f"Starting Basic Memory API {basic_memory.__version__}")
await db.run_migrations(app_config)
yield
logger.info("Shutting down Basic Memory API")
@@ -133,7 +133,7 @@ async def delete_entity(
return DeleteEntitiesResponse(deleted=False)
# Delete the entity
deleted = await entity_service.delete_entity(entity.permalink)
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
# Remove from search index
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
+16 -16
View File
@@ -29,34 +29,32 @@ async def to_graph_context(context, entity_repository: EntityRepository, page: i
async def to_summary(item: SearchIndexRow | ContextResultRow):
match item.type:
case SearchItemType.ENTITY:
assert item.title is not None
assert item.created_at is not None
return EntitySummary(
title=item.title,
title=item.title, # pyright: ignore
permalink=item.permalink,
file_path=item.file_path,
created_at=item.created_at,
)
case SearchItemType.OBSERVATION:
assert item.category is not None
assert item.content is not None
return ObservationSummary(
category=item.category, content=item.content, permalink=item.permalink
title=item.title, # pyright: ignore
file_path=item.file_path,
category=item.category, # pyright: ignore
content=item.content, # pyright: ignore
permalink=item.permalink, # pyright: ignore
created_at=item.created_at,
)
case SearchItemType.RELATION:
assert item.from_id is not None
from_entity = await entity_repository.find_by_id(item.from_id)
assert from_entity is not None
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
return RelationSummary(
permalink=item.permalink,
title=item.title, # pyright: ignore
file_path=item.file_path,
permalink=item.permalink, # pyright: ignore
relation_type=item.type,
from_id=from_entity.permalink,
from_id=from_entity.permalink, # pyright: ignore
to_id=to_entity.permalink if to_entity else None,
created_at=item.created_at,
)
case _: # pragma: no cover
raise ValueError(f"Unexpected type: {item.type}")
@@ -104,9 +102,11 @@ async def recent(
context = await context_service.build_context(
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(
recent_context = await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
logger.debug(f"Recent context: {recent_context.model_dump_json()}")
return recent_context
# get_memory_context needs to be declared last so other paths can match
+105 -4
View File
@@ -2,9 +2,10 @@
import tempfile
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
from fastapi.responses import FileResponse, JSONResponse
from loguru import logger
from basic_memory.deps import (
@@ -13,10 +14,13 @@ from basic_memory.deps import (
SearchServiceDep,
EntityServiceDep,
FileServiceDep,
EntityRepositoryDep,
)
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.models.knowledge import Entity as EntityModel
from datetime import datetime
router = APIRouter(prefix="/resource", tags=["resources"])
@@ -94,8 +98,7 @@ async def get_resource_content(
content = await file_service.read_entity_content(result)
memory_url = normalize_memory_url(result.permalink)
modified_date = result.updated_at.isoformat()
assert result.checksum
checksum = result.checksum[:8]
checksum = result.checksum[:8] if result.checksum else ""
# Prepare the delimited content
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
@@ -122,3 +125,101 @@ def cleanup_temp_file(file_path: str):
logger.debug(f"Temporary file deleted: {file_path}")
except Exception as e: # pragma: no cover
logger.error(f"Error deleting temporary file {file_path}: {e}")
@router.put("/{file_path:path}")
async def write_resource(
config: ProjectConfigDep,
file_service: FileServiceDep,
entity_repository: EntityRepositoryDep,
search_service: SearchServiceDep,
file_path: str,
content: Annotated[str, Body()],
) -> JSONResponse:
"""Write content to a file in the project.
This endpoint allows writing content directly to a file in the project.
Also creates an entity record and indexes the file for search.
Args:
file_path: Path to write to, relative to project root
request: Contains the content to write
Returns:
JSON response with file information
"""
try:
# Get content from request body
# Ensure it's UTF-8 string content
if isinstance(content, bytes): # pragma: no cover
content_str = content.decode("utf-8")
else:
content_str = str(content)
# Get full file path
full_path = Path(f"{config.home}/{file_path}")
# Ensure parent directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write content to file
checksum = await file_service.write_file(full_path, content_str)
# Get file info
file_stats = file_service.file_stats(full_path)
# Determine file details
file_name = Path(file_path).name
content_type = file_service.content_type(full_path)
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
# Check if entity already exists
existing_entity = await entity_repository.get_by_file_path(file_path)
if existing_entity:
# Update existing entity
entity = await entity_repository.update(
existing_entity.id,
{
"title": file_name,
"entity_type": entity_type,
"content_type": content_type,
"file_path": file_path,
"checksum": checksum,
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
},
)
status_code = 200
else:
# Create a new entity model
entity = EntityModel(
title=file_name,
entity_type=entity_type,
content_type=content_type,
file_path=file_path,
checksum=checksum,
created_at=datetime.fromtimestamp(file_stats.st_ctime),
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
)
entity = await entity_repository.add(entity)
status_code = 201
# Index the file for search
await search_service.index_entity(entity) # pyright: ignore
# Return success response
return JSONResponse(
status_code=status_code,
content={
"file_path": file_path,
"checksum": checksum,
"size": file_stats.st_size,
"created_at": file_stats.st_ctime,
"modified_at": file_stats.st_mtime,
},
)
except Exception as e: # pragma: no cover
logger.error(f"Error writing resource {file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
-2
View File
@@ -4,9 +4,7 @@ import typer
from basic_memory import db
from basic_memory.config import config
from basic_memory.utils import setup_logging
setup_logging(log_file=".basic-memory/basic-memory-cli.log", console=False) # pragma: no cover
asyncio.run(db.run_migrations(config))
+9 -21
View File
@@ -10,29 +10,16 @@ from rich.console import Console
from rich.panel import Panel
from rich.tree import Tree
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.sync import get_sync_service
from basic_memory.config import config
from basic_memory.db import DatabaseType
from basic_memory.repository import EntityRepository
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.utils import SyncReport
from basic_memory.sync import SyncService
from basic_memory.sync.sync_service import SyncReport
# Create rich console
console = Console()
async def get_file_change_scanner(
db_type=DatabaseType.FILESYSTEM,
) -> FileChangeScanner: # pragma: no cover
"""Get sync service instance."""
_, session_maker = await db.get_or_create_db(db_path=config.database_path, db_type=db_type)
entity_repository = EntityRepository(session_maker)
file_change_scanner = FileChangeScanner(entity_repository)
return file_change_scanner
def add_files_to_tree(
tree: Tree, paths: Set[str], style: str, checksums: Dict[str, str] | None = None
):
@@ -104,7 +91,7 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False):
"""Display changes using Rich for better visualization."""
tree = Tree(title)
if changes.total_changes == 0:
if changes.total == 0:
tree.add("No changes")
console.print(Panel(tree, expand=False))
return
@@ -135,11 +122,11 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False):
console.print(Panel(tree, expand=False))
async def run_status(sync_service: FileChangeScanner, verbose: bool = False):
async def run_status(sync_service: SyncService, verbose: bool = False):
"""Check sync status of files vs database."""
# Check knowledge/ directory
knowledge_changes = await sync_service.find_knowledge_changes(config.home)
display_changes("Knowledge Files", knowledge_changes, verbose)
knowledge_changes = await sync_service.scan(config.home)
display_changes("Status", knowledge_changes, verbose)
@app.command()
@@ -149,8 +136,9 @@ def status(
"""Show sync status between files and database."""
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
try:
sync_service = asyncio.run(get_file_change_scanner())
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
+12 -16
View File
@@ -25,8 +25,8 @@ from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync import SyncService, FileChangeScanner
from basic_memory.sync.utils import SyncReport
from basic_memory.sync import SyncService
from basic_memory.sync.sync_service import SyncReport
from basic_memory.sync.watch_service import WatchService
console = Console()
@@ -58,9 +58,6 @@ async def get_sync_service(): # pragma: no cover
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize scanner
file_change_scanner = FileChangeScanner(entity_repository)
# Initialize services
entity_service = EntityService(
entity_parser,
@@ -73,12 +70,12 @@ async def get_sync_service(): # pragma: no cover
# Create sync service
sync_service = SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
file_service=file_service,
)
return sync_service
@@ -95,7 +92,7 @@ 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_changes
total_changes = knowledge.total
if total_changes == 0:
console.print("[green]Everything up to date[/green]")
return
@@ -121,13 +118,13 @@ def display_sync_summary(knowledge: SyncReport):
def display_detailed_sync_results(knowledge: SyncReport):
"""Display detailed sync results with trees."""
if knowledge.total_changes == 0:
if knowledge.total == 0:
console.print("\n[green]Everything up to date[/green]")
return
console.print("\n[bold]Sync Results[/bold]")
if knowledge.total_changes > 0:
if knowledge.total > 0:
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
if knowledge.new:
created = knowledge_tree.add("[green]Created[/green]")
@@ -163,8 +160,10 @@ async def run_sync(verbose: bool = False, watch: bool = False, console_status: b
file_service=sync_service.entity_service.file_service,
config=config,
)
await watch_service.handle_changes(config.home)
await watch_service.run(console_status=console_status) # pragma: no cover
# full sync
await sync_service.sync(config.home)
# watch changes
await watch_service.run() # pragma: no cover
else:
# one time sync
knowledge_changes = await sync_service.sync(config.home)
@@ -189,18 +188,15 @@ def sync(
"-w",
help="Start watching for changes after sync.",
),
console_status: bool = typer.Option(
False, "--console-status", "-c", help="Show live console status"
),
) -> None:
"""Sync knowledge files with the database."""
try:
# Run sync
asyncio.run(run_sync(verbose=verbose, watch=watch, console_status=console_status))
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")
logger.exception("Sync failed", e)
typer.echo(f"Error during sync: {e}", err=True)
raise typer.Exit(1)
raise
+36 -13
View File
@@ -1,9 +1,10 @@
"""Database management commands."""
"""CLI tool commands for Basic Memory."""
import asyncio
from typing import Optional, List, Annotated
import typer
from loguru import logger
from rich import print as rprint
from basic_memory.cli.app import app
@@ -13,9 +14,15 @@ from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import search as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
# Import prompts
from basic_memory.mcp.prompts.continue_conversation import (
continue_conversation as mcp_continue_conversation,
)
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import MemoryUrl
from basic_memory.schemas.search import SearchQuery
from basic_memory.schemas.search import SearchQuery, SearchItemType
tool_app = typer.Typer()
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
@@ -72,7 +79,7 @@ def build_context(
max_related=max_related,
)
)
rprint(context.model_dump())
rprint(context.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
@@ -82,18 +89,13 @@ def build_context(
@tool_app.command()
def recent_activity(
type: Annotated[Optional[List[str]], typer.Option()] = ["entity", "observation", "relation"],
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
):
assert type is not None, "type is required"
if any(t not in ["entity", "observation", "relation"] for t in type): # pragma: no cover
print("type must be one of ['entity', 'observation', 'relation']")
raise typer.Abort()
try:
context = asyncio.run(
mcp_recent_activity(
@@ -105,7 +107,7 @@ def recent_activity(
max_related=max_related,
)
)
rprint(context.model_dump())
rprint(context.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
@@ -132,14 +134,15 @@ def search(
try:
search_query = SearchQuery(
permalink_match=query if permalink else None,
text=query if query else None,
text=query if not (permalink or title) else None,
title=query if title else None,
after_date=after_date,
)
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
rprint(results.model_dump())
rprint(results.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error during search", e)
typer.echo(f"Error during search: {e}", err=True)
raise typer.Exit(1)
raise
@@ -149,9 +152,29 @@ def search(
def get_entity(identifier: str):
try:
entity = asyncio.run(mcp_get_entity(identifier=identifier))
rprint(entity.model_dump())
rprint(entity.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during get_entity: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command(name="continue-conversation")
def continue_conversation(
topic: Annotated[Optional[str], typer.Option(help="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[str], typer.Option(help="How far back to look for activity")
] = None,
):
"""Continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation(topic=topic, 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
View File
@@ -15,6 +15,5 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
tools,
)
if __name__ == "__main__": # pragma: no cover
app()
+15 -1
View File
@@ -3,9 +3,13 @@
from pathlib import Path
from typing import Literal
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import basic_memory
from basic_memory.utils import setup_logging
DATABASE_NAME = "memory.db"
DATA_DIR_NAME = ".basic-memory"
@@ -31,7 +35,7 @@ class ProjectConfig(BaseSettings):
default=500, description="Milliseconds to wait after changes before syncing", gt=0
)
log_level: str = "INFO"
log_level: str = "DEBUG"
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
@@ -60,3 +64,13 @@ class ProjectConfig(BaseSettings):
# Load project config
config = ProjectConfig()
# setup logging
setup_logging(
env=config.env,
home_dir=config.home,
log_level=config.log_level,
log_file=".basic-memory/basic-memory.log",
console=False,
)
logger.info(f"Starting Basic Memory {basic_memory.__version__}")
+6 -4
View File
@@ -2,7 +2,7 @@
import hashlib
from pathlib import Path
from typing import Dict, Any
from typing import Dict, Any, Union
import yaml
from loguru import logger
@@ -26,12 +26,12 @@ class ParseError(FileError):
pass
async def compute_checksum(content: str) -> str:
async def compute_checksum(content: Union[str, bytes]) -> str:
"""
Compute SHA-256 checksum of content.
Args:
content: Text content to hash
content: Content to hash (either text string or bytes)
Returns:
SHA-256 hex digest
@@ -40,7 +40,9 @@ async def compute_checksum(content: str) -> str:
FileError: If checksum computation fails
"""
try:
return hashlib.sha256(content.encode()).hexdigest()
if isinstance(content, str):
content = content.encode()
return hashlib.sha256(content).hexdigest()
except Exception as e: # pragma: no cover
logger.error(f"Failed to compute checksum: {e}")
raise FileError(f"Failed to compute checksum: {e}")
+3 -3
View File
@@ -88,10 +88,10 @@ class EntityParser:
return parsed
return None
async def parse_file(self, file_path: Path) -> EntityMarkdown:
async def parse_file(self, path: Path | str) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""
absolute_path = self.base_path / file_path
absolute_path = self.base_path / path
# Parse frontmatter and content using python-frontmatter
post = frontmatter.load(str(absolute_path))
@@ -99,7 +99,7 @@ class EntityParser:
file_stats = absolute_path.stat()
metadata = post.metadata
metadata["title"] = post.metadata.get("title", file_path.name)
metadata["title"] = post.metadata.get("title", absolute_path.name)
metadata["type"] = post.metadata.get("type", "note")
metadata["tags"] = parse_tags(post.metadata.get("tags", []))
+1 -1
View File
@@ -2,7 +2,7 @@ from httpx import ASGITransport, AsyncClient
from basic_memory.api.app import app as fastapi_app
BASE_URL = "memory://"
BASE_URL = "http://test"
# Create shared async client
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
+25
View File
@@ -0,0 +1,25 @@
"""Main MCP entrypoint for Basic Memory.
Creates and configures the shared MCP instance and handles server startup.
"""
from loguru import logger # pragma: no cover
from basic_memory.config import config # pragma: no cover
# Import shared mcp instance
from basic_memory.mcp.server import mcp # pragma: no cover
# Import tools to register them
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
# Import prompts to register them
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
if __name__ == "__main__": # pragma: no cover
home_dir = config.home
logger.info("Starting Basic Memory MCP server")
logger.info(f"Home directory: {home_dir}")
mcp.run()
+15
View File
@@ -0,0 +1,15 @@
"""Basic Memory MCP prompts.
Prompts are a special type of tool that returns a string response
formatted for a user to read, typically invoking one or more tools
and transforming their results into user-friendly text.
"""
# Import individual prompt modules to register them with the MCP server
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"]
@@ -0,0 +1,28 @@
from pathlib import Path
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@mcp.resource(
uri="memory://ai_assistant_guide",
name="ai_assistant_guide",
description="Give an AI assistant guidance on how to use Basic Memory tools effectively",
)
def ai_assistant_guide() -> str:
"""Return a concise guide on Basic Memory tools and how to use them.
Args:
focus: Optional area to focus on ("writing", "context", "search", etc.)
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
@@ -0,0 +1,172 @@
"""Session continuation prompts for Basic Memory MCP server.
These prompts help users continue conversations and work across sessions,
providing context from previous interactions to maintain continuity.
"""
from textwrap import dedent
from typing import Optional, List, Annotated
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.memory import build_context, recent_activity
from basic_memory.mcp.tools.search import search
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext
from basic_memory.schemas.search import SearchQuery
@mcp.prompt(
name="continue_conversation",
description="Continue a previous conversation",
)
async def continue_conversation(
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Continue a previous conversation or work session.
This prompt helps you pick up where you left off by finding recent context
about a specific topic or showing general recent activity.
Args:
topic: Topic or keyword to search for (optional)
timeframe: How far back to look for activity
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}")
# If topic provided, search for it
if topic:
search_results = await search(SearchQuery(text=topic, after_date=timeframe))
# Build context from top results
contexts = []
for result in search_results.results[:3]:
if hasattr(result, "permalink") and result.permalink:
context = await build_context(f"memory://{result.permalink}")
contexts.append(context)
return format_continuation_context(topic, contexts, timeframe)
# If no topic, get recent activity
recent = await recent_activity(timeframe=timeframe)
return format_continuation_context("Recent Activity", [recent], timeframe)
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"
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_id") and rel.to_id:
section += f" - `{rel.to_id}`\n"
sections.append(section)
# Add all sections
summary += "\n".join(sections)
# Add next steps
next_steps = dedent(f"""
## Next Steps
You can:
- Explore more with: `search({{"text": "{topic}"}})`
- See what's changed: `recent_activity(timeframe="{timeframe}")`
""")
# 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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,46 @@
"""Recent activity prompts for Basic Memory MCP server.
These prompts help users see what has changed in their knowledge base recently.
"""
from typing import Annotated, Optional
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_context_summary
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.memory import recent_activity as recent_activity_tool
from basic_memory.schemas.base import TimeFrame
@mcp.prompt(
name="recent_activity",
description="Get recent activity from across the knowledge base",
)
async def recent_activity_prompt(
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Get recent activity from across the knowledge base.
This prompt helps you see what's changed recently in the knowledge base,
showing new or updated documents and related information.
Args:
timeframe: How far back to look for activity (e.g. '1d', '1 week')
Returns:
Formatted summary of recent activity
"""
with logfire.span("Getting recent activity", timeframe=timeframe): # pyright: ignore
logger.info(f"Getting recent activity, timeframe: {timeframe}")
results = await recent_activity_tool(timeframe=timeframe)
time_display = f" ({timeframe})" if timeframe else ""
header = f"# Recent Activity{time_display}"
return format_context_summary(header, results)
+127
View File
@@ -0,0 +1,127 @@
"""Search prompts for Basic Memory MCP server.
These prompts help users search and explore their knowledge base.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search as search_tool
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.schemas.base import TimeFrame
@mcp.prompt(
name="search",
description="Search across all content in basic-memory",
)
async def search_prompt(
query: str,
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to search (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Search across all content in basic-memory.
This prompt helps search for content in the knowledge base and
provides helpful context about the results.
Args:
query: The search text to look for
timeframe: Optional timeframe to limit results (e.g. '1d', '1 week')
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}")
search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
return format_search_results(query, search_results, timeframe)
def format_search_results(
query: str, results: SearchResponse, timeframe: Optional[TimeFrame] = None
) -> str:
"""Format search results into a helpful summary.
Args:
query: The search query
results: Search results object
timeframe: How far back results were searched
Returns:
Formatted search results summary
"""
if not results.results:
return dedent(f"""
# Search Results for: "{query}"
I couldn't find any results for this query.
## 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
time_info = f" (after {timeframe})" if timeframe else ""
summary = dedent(f"""
# Search Results for: "{query}"{time_info}
This is a memory search session.
Please use the available basic-memory tools to gather relevant context before responding.
I found {len(results.results)} results that match your query.
Here are the most relevant results:
""")
# Add each search result
for i, result in enumerate(results.results[:5]): # Limit to top 5 results
summary += dedent(f"""
## {i + 1}. {result.title}
- **Type**: {result.type}
""")
# Add creation date if available in metadata
if hasattr(result, "metadata") and 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"
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"
# Add permalink for retrieving content
if hasattr(result, "permalink") and 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}")`
""")
# Add next steps
summary += dedent(f"""
## Next Steps
You can:
- Refine your search: `search("{query} AND additional_term")`
- Exclude terms: `search("{query} NOT exclude_term")`
- View more results: `search("{query}", after_date=None)`
- Check recent activity: `recent_activity()`
""")
return summary
+98
View File
@@ -0,0 +1,98 @@
"""Utility functions for formatting prompt responses.
These utilities help format data from various tools into consistent,
user-friendly markdown summaries.
"""
from basic_memory.schemas.memory import GraphContext
def format_context_summary(header: str, context: GraphContext) -> str:
"""Format GraphContext as a helpful markdown summary.
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
Returns:
Formatted markdown string with the context summary
"""
summary = []
# 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}")` '
)
summary.append("")
else:
summary.append("\nNo primary documents found.")
# Related documents section
if context.related_results:
summary.append(f"## Related Documents ({len(context.related_results)})")
# 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)
# Display relations grouped by type
for rel_type, relations in relation_types.items():
summary.append(f"### {rel_type.replace('_', ' ').title()} ({len(relations)})")
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}")` '
)
else:
summary.append(f"- **Unresolved relation**: {rel.permalink}")
summary.append("")
# Next steps section
summary.append("## Next Steps")
summary.append("Here are some ways to explore further:")
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)
+3 -7
View File
@@ -1,15 +1,11 @@
"""Enhanced FastMCP server instance for Basic Memory."""
from mcp.server.fastmcp import FastMCP
from basic_memory.utils import setup_logging
from mcp.server.fastmcp.utilities.logging import configure_logging
# mcp console logging
# configure_logging(level='INFO')
configure_logging(level="INFO")
# start our out file logging
setup_logging(log_file=".basic-memory/basic-memory.log")
# Create the shared server instance
mcp = FastMCP("Basic Memory")
mcp = FastMCP("Basic Memory")
+6 -4
View File
@@ -6,11 +6,11 @@ all tools with the MCP server.
"""
# Import tools to register them with MCP
from basic_memory.mcp.tools.resource import read_resource
from basic_memory.mcp.tools.memory import build_context, recent_activity
# from basic_memory.mcp.tools.ai_edit import ai_edit
from basic_memory.mcp.tools.notes import read_note, write_note
from basic_memory.mcp.tools.search import search
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.knowledge import (
delete_entities,
@@ -31,6 +31,8 @@ __all__ = [
# notes
"read_note",
"write_note",
# file edit
# "ai_edit",
# files
"read_resource",
# canvas
"canvas",
]
+99
View File
@@ -0,0 +1,99 @@
"""Canvas creation tool for Basic Memory MCP server.
This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec.
"""
import json
from typing import Dict, List, Any
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.utils import call_put
@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
)
async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
title: str,
folder: str,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
This tool creates a .canvas file compatible with Obsidian's Canvas feature,
allowing visualization of relationships between concepts or documents.
For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource.
Args:
nodes: List of node objects following JSON Canvas 1.0 spec
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: The folder where the file should be saved
Returns:
A summary of the created canvas file
Important Notes:
- When referencing files, use the exact file path as shown in Obsidian
Example: "folder/Document Name.md" (not permalink format)
- For file nodes, the "file" attribute must reference an existing file
- Nodes require id, type, x, y, width, height properties
- Edges require id, fromNode, toNode properties
- Position nodes in a logical layout (x,y coordinates in pixels)
- Use color attributes ("1"-"6" or hex) for visual organization
Basic Structure:
```json
{
"nodes": [
{
"id": "node1",
"type": "file", // Options: "file", "text", "link", "group"
"file": "folder/Document.md",
"x": 0,
"y": 0,
"width": 400,
"height": 300
}
],
"edges": [
{
"id": "edge1",
"fromNode": "node1",
"toNode": "node2",
"label": "connects to"
}
]
}
```
"""
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}"
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# 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)
# 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."]
return "\n".join(summary)
+12 -5
View File
@@ -1,6 +1,6 @@
"""Discussion context tools for Basic Memory MCP server."""
from typing import Optional, Literal, List
from typing import Optional, List
from loguru import logger
import logfire
@@ -15,6 +15,7 @@ from basic_memory.schemas.memory import (
normalize_memory_url,
)
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.search import SearchItemType
@mcp.tool(
@@ -100,7 +101,7 @@ async def build_context(
""",
)
async def recent_activity(
type: List[Literal["entity", "observation", "relation"]] = [],
type: Optional[List[SearchItemType]] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
@@ -153,14 +154,20 @@ async def recent_activity(
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
)
params = {
"depth": depth,
"timeframe": timeframe,
"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"] = type
params["type"] = [ # pyright: ignore
type.value if isinstance(type, SearchItemType) else type for type in type
]
response = await call_get(
client,
+1 -2
View File
@@ -84,9 +84,8 @@ async def write_note(
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
assert result.checksum is not None
summary = [
f"# {action} {result.file_path} ({result.checksum[:8]})",
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
f"permalink: {result.permalink}",
]
+192
View File
@@ -0,0 +1,192 @@
from loguru import logger
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import memory_url_path
import base64
import io
from PIL import Image as PILImage
def calculate_target_params(content_length):
"""Calculate initial quality and size based on input file size"""
target_size = 350000 # Reduced target for more safety margin
ratio = content_length / target_size
logger.debug(
"Calculating target parameters",
content_length=content_length,
ratio=ratio,
target_size=target_size,
)
if ratio > 4:
# Very large images - start very aggressive
return 50, 600 # Lower initial quality and size
elif ratio > 2:
return 60, 800
else:
return 70, 1000
def resize_image(img, max_size):
"""Resize image maintaining aspect ratio"""
original_dimensions = {"width": img.width, "height": img.height}
if img.width > max_size or img.height > max_size:
ratio = min(max_size / img.width, max_size / img.height)
new_size = (int(img.width * ratio), int(img.height * ratio))
logger.debug("Resizing image", original=original_dimensions, target=new_size, ratio=ratio)
return img.resize(new_size, PILImage.Resampling.LANCZOS)
logger.debug("No resize needed", dimensions=original_dimensions)
return img
def optimize_image(img, content_length, max_output_bytes=350000):
"""Iteratively optimize image with aggressive size reduction"""
stats = {
"dimensions": {"width": img.width, "height": img.height},
"mode": img.mode,
"estimated_memory": (img.width * img.height * len(img.getbands())),
}
initial_quality, initial_size = calculate_target_params(content_length)
logger.debug(
"Starting optimization",
image_stats=stats,
content_length=content_length,
initial_quality=initial_quality,
initial_size=initial_size,
max_output_bytes=max_output_bytes,
)
quality = initial_quality
size = initial_size
# Convert to RGB if needed
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGB")
logger.debug("Converted to RGB mode")
iteration = 0
min_size = 300 # Absolute minimum size
min_quality = 20 # Absolute minimum quality
while True:
iteration += 1
buf = io.BytesIO()
resized = resize_image(img, size)
resized.save(
buf,
format="JPEG",
quality=quality,
optimize=True,
progressive=True,
subsampling="4:2:0",
)
output_size = buf.getbuffer().nbytes
reduction_ratio = output_size / content_length
logger.debug(
"Optimization attempt",
iteration=iteration,
quality=quality,
size=size,
output_bytes=output_size,
target_bytes=max_output_bytes,
reduction_ratio=f"{reduction_ratio:.2f}",
)
if output_size < max_output_bytes:
logger.info(
"Image optimization complete",
final_size=output_size,
quality=quality,
dimensions={"width": resized.width, "height": resized.height},
reduction_ratio=f"{reduction_ratio:.2f}",
)
return buf.getvalue()
# Very aggressive reduction for large files
if content_length > 2000000: # 2MB+ # pragma: no cover
quality = max(min_quality, quality - 20)
size = max(min_size, int(size * 0.6))
elif content_length > 1000000: # 1MB+ # pragma: no cover
quality = max(min_quality, quality - 15)
size = max(min_size, int(size * 0.7))
else:
quality = max(min_quality, quality - 10) # pragma: no cover
size = max(min_size, int(size * 0.8)) # pragma: no cover
logger.debug("Reducing parameters", new_quality=quality, new_size=size) # pragma: no cover
# If we've hit minimum values and still too big
if quality <= min_quality and size <= min_size: # pragma: no cover
logger.warning(
"Reached minimum parameters",
final_size=output_size,
over_limit_by=output_size - max_output_bytes,
)
return buf.getvalue()
@mcp.tool(description="Read a single file's content by path or permalink")
async def read_resource(path: str) -> dict:
"""Get a file's raw content."""
logger.info("Reading resource", path=path)
url = memory_url_path(path)
response = await call_get(client, f"/resource/{url}")
content_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0))
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
# Handle text or json
if content_type.startswith("text/") or content_type == "application/json":
logger.debug("Processing text resource")
return {
"type": "text",
"text": response.text,
"content_type": content_type,
"encoding": "utf-8",
}
# Handle images
elif content_type.startswith("image/"):
logger.debug("Processing image")
img = PILImage.open(io.BytesIO(response.content))
img_bytes = optimize_image(img, content_length)
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.b64encode(img_bytes).decode("utf-8"),
},
}
# Handle other file types
else:
logger.debug(f"Processing binary resource content_type {content_type}")
if content_length > 350000:
logger.warning("Document too large for response", size=content_length)
return {
"type": "error",
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
}
return {
"type": "document",
"source": {
"type": "base64",
"media_type": content_type,
"data": base64.b64encode(response.content).decode("utf-8"),
},
}
+2 -1
View File
@@ -44,7 +44,7 @@ async def call_get(
response.raise_for_status()
return response
except HTTPStatusError as e:
logger.error(f"Error calling GET {url}: {e}")
logger.exception(f"Error calling GET {url}: {e}")
raise ToolError(f"Error calling tool: {e}.") from e
@@ -79,6 +79,7 @@ async def call_put(
timeout=timeout,
extensions=extensions,
)
logger.debug(response)
response.raise_for_status()
return response
except HTTPStatusError as e:
+27 -11
View File
@@ -12,6 +12,7 @@ from sqlalchemy import (
DateTime,
Index,
JSON,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -32,11 +33,18 @@ class Entity(Base):
__tablename__ = "entity"
__table_args__ = (
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
# Regular indexes
Index("ix_entity_type", "entity_type"),
Index("ix_entity_title", "title"),
Index("ix_entity_created_at", "created_at"), # For timeline queries
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
# Unique index only for markdown files with non-null permalinks
Index(
"uix_entity_permalink",
"permalink",
unique=True,
sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
),
)
# Core identity
@@ -46,8 +54,8 @@ class Entity(Base):
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
content_type: Mapped[str] = mapped_column(String)
# Normalized path for URIs
permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
# Normalized path for URIs - required for markdown files only
permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
# Actual filesystem relative path
file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
# checksum of file
@@ -79,6 +87,11 @@ class Entity(Base):
"""Get all relations (incoming and outgoing) for this entity."""
return self.incoming_relations + self.outgoing_relations
@property
def is_markdown(self):
"""Check if the entity is a markdown file."""
return self.content_type == "text/markdown"
def __repr__(self) -> str:
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
@@ -127,7 +140,10 @@ class Relation(Base):
__tablename__ = "relation"
__table_args__ = (
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
UniqueConstraint(
"from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
),
Index("ix_relation_type", "relation_type"),
Index("ix_relation_from_id", "from_id"), # Add FK indexes
Index("ix_relation_to_id", "to_id"),
@@ -155,13 +171,13 @@ class Relation(Base):
Format: source/relation_type/target
Example: "specs/search/implements/features/search-ui"
"""
# Only create permalinks when both source and target have permalinks
from_permalink = self.from_entity.permalink or self.from_entity.file_path
if self.to_entity:
return generate_permalink(
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
)
return generate_permalink(
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
)
to_permalink = self.to_entity.permalink or self.to_entity.file_path
return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
def __repr__(self) -> str:
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')" # pragma: no cover
+1 -1
View File
@@ -97,7 +97,7 @@ class Repository[T: Base]:
entities = (self.Model,)
return select(*entities)
async def find_all(self, skip: int = 0, limit: Optional[int] = 0) -> Sequence[T]:
async def find_all(self, skip: int = 0, limit: Optional[int] = None) -> Sequence[T]:
"""Fetch records from the database with pagination."""
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
@@ -21,13 +21,14 @@ class SearchIndexRow:
id: int
type: str
permalink: str
file_path: str
metadata: Optional[dict] = None
# date values
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
permalink: Optional[str] = None
metadata: Optional[dict] = None
# assigned in result
score: Optional[float] = None
@@ -265,6 +266,15 @@ class SearchRepository:
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index by entity_id."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text("DELETE FROM search_index WHERE entity_id = :entity_id"),
{"entity_id": entity_id},
)
await session.commit()
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
async with db.scoped_session(self.session_maker) as session:
-11
View File
@@ -37,13 +37,6 @@ from basic_memory.schemas.response import (
DeleteEntitiesResponse,
)
# Discovery and analytics models
from basic_memory.schemas.discovery import (
EntityTypeList,
ObservationCategoryList,
TypedEntityList,
)
# For convenient imports, export all models
__all__ = [
# Base
@@ -66,8 +59,4 @@ __all__ = [
"DeleteEntitiesResponse",
# Delete Operations
"DeleteEntitiesRequest",
# Discovery and Analytics
"EntityTypeList",
"ObservationCategoryList",
"TypedEntityList",
]
+4 -1
View File
@@ -159,7 +159,10 @@ class Entity(BaseModel):
@property
def file_path(self):
"""Get the file path for this entity based on its permalink."""
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
if self.content_type == "text/markdown":
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
else:
return f"{self.folder}/{self.title}" if self.folder else self.title
@property
def permalink(self) -> Permalink:
-28
View File
@@ -1,28 +0,0 @@
"""Schemas for knowledge discovery and analytics endpoints."""
from typing import List, Optional
from pydantic import BaseModel, Field
from basic_memory.schemas.response import EntityResponse
class EntityTypeList(BaseModel):
"""List of unique entity types in the system."""
types: List[str]
class ObservationCategoryList(BaseModel):
"""List of unique observation categories in the system."""
categories: List[str]
class TypedEntityList(BaseModel):
"""List of entities of a specific type."""
entity_type: str = Field(..., description="Type of entities in the list")
entities: List[EntityResponse]
total: int = Field(..., description="Total number of entities")
sort_by: Optional[str] = Field(None, description="Field used for sorting")
include_related: bool = Field(False, description="Whether related entities are included")
+11 -2
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
from basic_memory.schemas.search import SearchItemType
def normalize_memory_url(url: str) -> str:
def normalize_memory_url(url: str | None) -> str:
"""Normalize a MemoryUrl string.
Args:
@@ -24,6 +24,9 @@ def normalize_memory_url(url: str) -> str:
>>> normalize_memory_url("memory://specs/search")
'memory://specs/search'
"""
if not url:
return ""
clean_path = url.removeprefix("memory://")
return f"memory://{clean_path}"
@@ -59,7 +62,7 @@ class EntitySummary(BaseModel):
"""Simplified entity representation."""
type: str = "entity"
permalink: str
permalink: Optional[str]
title: str
file_path: str
created_at: datetime
@@ -69,19 +72,25 @@ class RelationSummary(BaseModel):
"""Simplified relation representation."""
type: str = "relation"
title: str
file_path: str
permalink: str
relation_type: str
from_id: str
to_id: Optional[str] = None
created_at: datetime
class ObservationSummary(BaseModel):
"""Simplified observation representation."""
type: str = "observation"
title: str
file_path: str
permalink: str
category: str
content: str
created_at: datetime
class MemoryMetadata(BaseModel):
+2 -1
View File
@@ -68,9 +68,10 @@ class SearchResult(BaseModel):
"""Search result with score and metadata."""
id: int
title: str
type: SearchItemType
score: float
permalink: str
permalink: Optional[str]
file_path: str
metadata: Optional[dict] = None
+19 -12
View File
@@ -124,17 +124,19 @@ class EntityService(BaseService[EntityModel]):
entity_markdown = await self.entity_parser.parse_file(file_path)
# create entity
await self.create_entity_from_markdown(file_path, entity_markdown)
created = await self.create_entity_from_markdown(file_path, entity_markdown)
# add relations
entity = await self.update_entity_relations(file_path, entity_markdown)
entity = await self.update_entity_relations(created.file_path, entity_markdown)
# Set final checksum to mark complete
return await self.repository.update(entity.id, {"checksum": checksum})
async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
"""Update an entity's content and metadata."""
logger.debug(f"Updating entity with permalink: {entity.permalink}")
logger.debug(
f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}"
)
# Convert file path string to Path
file_path = Path(entity.file_path)
@@ -152,20 +154,25 @@ class EntityService(BaseService[EntityModel]):
entity = await self.update_entity_and_observations(file_path, entity_markdown)
# add relations
await self.update_entity_relations(file_path, entity_markdown)
await self.update_entity_relations(str(file_path), entity_markdown)
# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
return entity
async def delete_entity(self, permalink: str) -> bool:
async def delete_entity(self, permalink_or_id: str | int) -> bool:
"""Delete entity and its file."""
logger.debug(f"Deleting entity: {permalink}")
logger.debug(f"Deleting entity: {permalink_or_id}")
try:
# Get entity first for file deletion
entity = await self.get_by_permalink(permalink)
if isinstance(permalink_or_id, str):
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)}"
entity = entities[0]
# Delete file first
await self.file_service.delete_entity_file(entity)
@@ -174,7 +181,7 @@ class EntityService(BaseService[EntityModel]):
return await self.repository.delete(entity.id)
except EntityNotFoundError:
logger.info(f"Entity not found: {permalink}")
logger.info(f"Entity not found: {permalink_or_id}")
return True # Already deleted
async def get_by_permalink(self, permalink: str) -> EntityModel:
@@ -256,13 +263,13 @@ class EntityService(BaseService[EntityModel]):
async def update_entity_relations(
self,
file_path: Path,
path: str,
markdown: EntityMarkdown,
) -> EntityModel:
"""Update relations for entity"""
logger.debug(f"Updating relations for entity: {file_path}")
logger.debug(f"Updating relations for entity: {path}")
db_entity = await self.repository.get_by_file_path(str(file_path))
db_entity = await self.repository.get_by_file_path(path)
# Clear existing relations first
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
@@ -296,4 +303,4 @@ class EntityService(BaseService[EntityModel]):
)
continue
return await self.repository.get_by_file_path(str(file_path))
return await self.repository.get_by_file_path(path)
+69 -2
View File
@@ -1,11 +1,14 @@
"""Service for file operations with checksum tracking."""
import mimetypes
from os import stat_result
from pathlib import Path
from typing import Tuple, Union
from typing import Tuple, Union, Dict, Any
from loguru import logger
from basic_memory import file_utils
from basic_memory.file_utils import FileError
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Entity as EntityModel
from basic_memory.schemas import Entity as EntitySchema
@@ -134,6 +137,7 @@ class FileService:
logger.error(f"Failed to write file {full_path}: {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]:
"""Read file and compute checksum.
@@ -153,7 +157,7 @@ class FileService:
full_path = path if path.is_absolute() else self.base_path / path
try:
content = path.read_text()
content = full_path.read_text()
checksum = await file_utils.compute_checksum(content)
logger.debug(f"read file: {full_path}, checksum: {checksum}")
return content, checksum
@@ -174,3 +178,66 @@ class FileService:
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
full_path.unlink(missing_ok=True)
async def update_frontmatter(self, path: Union[Path, str], 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
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
try:
if self.is_markdown(path):
# read str
content = full_path.read_text()
else:
# read bytes
content = full_path.read_bytes()
return await file_utils.compute_checksum(content)
except Exception as e: # pragma: no cover
logger.error(f"Failed to compute checksum for {path}: {e}")
raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: Union[Path, str]) -> stat_result:
"""
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
# get file timestamps
return full_path.stat()
def content_type(self, path: Union[Path, str]) -> str:
"""
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
# get file timestamps
mime_type, _ = mimetypes.guess_type(full_path.name)
# .canvas files are json
if full_path.suffix == ".canvas":
mime_type = "application/json"
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:
"""
return self.content_type(path) == "text/markdown"
+11 -8
View File
@@ -4,11 +4,11 @@ from typing import Optional, Tuple, List
from loguru import logger
from basic_memory.models import Entity
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.services.search_service import SearchService
from basic_memory.models import Entity
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
class LinkResolver:
@@ -58,7 +58,8 @@ class LinkResolver:
logger.debug(
f"Selected best match from {len(results)} results: {best_match.permalink}"
)
return await self.entity_repository.get_by_permalink(best_match.permalink)
if best_match.permalink:
return await self.entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
return None
@@ -103,12 +104,14 @@ class LinkResolver:
scored_results = []
for result in results:
# Start with base score (lower is better)
score = result.score
assert score is not None
score = result.score or 0
# Parse path components
path_parts = result.permalink.lower().split("/")
last_part = path_parts[-1] if path_parts else ""
if result.permalink:
# Parse path components
path_parts = result.permalink.lower().split("/")
last_part = path_parts[-1] if path_parts else ""
else:
last_part = "" # pragma: no cover
# Title word match boosts
term_matches = [term for term in terms if term in last_part]
+56 -12
View File
@@ -3,6 +3,7 @@
from datetime import datetime
from typing import List, Optional, Set
from dateparser import parse
from fastapi import BackgroundTasks
from loguru import logger
@@ -69,7 +70,7 @@ class SearchService:
(
query.after_date
if isinstance(query.after_date, datetime)
else datetime.fromisoformat(query.after_date)
else parse(query.after_date)
)
if query.after_date
else None
@@ -118,6 +119,46 @@ class SearchService:
self,
entity: Entity,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
if background_tasks:
background_tasks.add_task(self.index_entity_data, entity)
else:
await self.index_entity_data(entity)
async def index_entity_data(
self,
entity: Entity,
) -> None:
# delete all search index data associated with entity
await self.repository.delete_by_entity_id(entity_id=entity.id)
# reindex
await self.index_entity_markdown(
entity
) if entity.is_markdown else await self.index_entity_file(entity)
async def index_entity_file(
self,
entity: Entity,
) -> None:
# Index entity file with no content
await self.repository.index_item(
SearchIndexRow(
id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
file_path=entity.file_path,
metadata={
"entity_type": entity.entity_type,
},
created_at=entity.created_at,
updated_at=entity.updated_at,
)
)
async def index_entity_markdown(
self,
entity: Entity,
) -> None:
"""Index an entity and all its observations and relations.
@@ -136,16 +177,10 @@ class SearchService:
Each type gets its own row in the search index with appropriate metadata.
"""
if background_tasks:
background_tasks.add_task(self.index_entity_data, entity)
else:
await self.index_entity_data(entity)
async def index_entity_data(
self,
entity: Entity,
) -> None:
"""Actually perform the indexing."""
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
content_parts = []
title_variants = self._generate_variants(entity.title)
@@ -160,6 +195,9 @@ class SearchService:
entity_content = "\n".join(p for p in content_parts if p and p.strip())
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
# Index entity
await self.repository.index_item(
SearchIndexRow(
@@ -169,6 +207,7 @@ class SearchService:
content=entity_content,
permalink=entity.permalink,
file_path=entity.file_path,
entity_id=entity.id,
metadata={
"entity_type": entity.entity_type,
},
@@ -214,6 +253,7 @@ class SearchService:
permalink=rel.permalink,
file_path=entity.file_path,
type=SearchItemType.RELATION.value,
entity_id=entity.id,
from_id=rel.from_id,
to_id=rel.to_id,
relation_type=rel.relation_type,
@@ -222,6 +262,10 @@ class SearchService:
)
)
async def delete_by_permalink(self, path_id: str):
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
await self.repository.delete_by_permalink(path_id)
await self.repository.delete_by_permalink(permalink)
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index."""
await self.repository.delete_by_entity_id(entity_id)
+3 -2
View File
@@ -1,5 +1,6 @@
from .file_change_scanner import FileChangeScanner
"""Basic Memory sync services."""
from .sync_service import SyncService
from .watch_service import WatchService
__all__ = ["SyncService", "FileChangeScanner", "WatchService"]
__all__ = ["SyncService", "WatchService"]
@@ -1,158 +0,0 @@
"""Service for detecting changes between filesystem and database."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Sequence
from loguru import logger
from basic_memory.file_utils import compute_checksum
from basic_memory.models import Entity
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.sync.utils import SyncReport
@dataclass
class FileState:
"""State of a file including file path, permalink and checksum info."""
file_path: str
permalink: str
checksum: str
@dataclass
class ScanResult:
"""Result of scanning a directory."""
# file_path -> checksum
files: Dict[str, str] = field(default_factory=dict)
# file_path -> error message
errors: Dict[str, str] = field(default_factory=dict)
class FileChangeScanner:
"""
Service for detecting changes between filesystem and database.
The filesystem is treated as the source of truth.
"""
def __init__(self, entity_repository: EntityRepository):
self.entity_repository = entity_repository
async def scan_directory(self, directory: Path) -> ScanResult:
"""
Scan directory for markdown files and their checksums.
Only processes .md files, logs and skips others.
Args:
directory: Directory to scan
Returns:
ScanResult containing found files and any errors
"""
logger.debug(f"Scanning directory: {directory}")
result = ScanResult()
if not directory.exists():
logger.debug(f"Directory does not exist: {directory}")
return result
for path in directory.rglob("*"):
if not path.is_file() or not path.name.endswith(".md"):
if path.is_file():
logger.debug(f"Skipping non-markdown file: {path}")
continue
try:
# Get relative path first - used in error reporting if needed
rel_path = str(path.relative_to(directory))
content = path.read_text()
checksum = await compute_checksum(content)
result.files[rel_path] = checksum
except Exception as e:
rel_path = str(path.relative_to(directory))
result.errors[rel_path] = str(e)
logger.error(f"Failed to read {rel_path}: {e}")
logger.debug(f"Found {len(result.files)} markdown files")
if result.errors:
logger.warning(f"Encountered {len(result.errors)} errors while scanning")
return result
async def find_changes(
self, directory: Path, db_file_state: Dict[str, FileState]
) -> SyncReport:
"""Find changes between filesystem and database."""
# Get current files and checksums
scan_result = await self.scan_directory(directory)
current_files = scan_result.files
# Build report
report = SyncReport(total=len(current_files))
# Track potentially moved files by checksum
files_by_checksum = {} # checksum -> file_path
# First find potential new files and record checksums
for file_path, checksum in current_files.items():
logger.debug(f"{file_path} ({checksum[:8]})")
if file_path not in db_file_state:
# Could be new or could be the destination of a move
report.new.add(file_path)
files_by_checksum[checksum] = file_path
elif checksum != db_file_state[file_path].checksum:
report.modified.add(file_path)
report.checksums[file_path] = checksum
# Now detect moves and deletions
for db_file_path, db_state in db_file_state.items():
if db_file_path not in current_files:
if db_state.checksum in files_by_checksum:
# Found a move - file exists at new path with same checksum
new_path = files_by_checksum[db_state.checksum]
report.moves[db_file_path] = new_path
# Remove from new files since it's a move
report.new.remove(new_path)
else:
# Actually deleted
report.deleted.add(db_file_path)
# Log summary
logger.debug(f"Total files: {report.total}")
logger.debug(f"Changes found: {report.total_changes}")
logger.debug(f" New: {len(report.new)}")
logger.debug(f" Modified: {len(report.modified)}")
logger.debug(f" Moved: {len(report.moves)}")
logger.debug(f" Deleted: {len(report.deleted)}")
if scan_result.errors: # pragma: no cover
logger.warning("Files skipped due to errors:")
for file_path, error in scan_result.errors.items():
logger.warning(f" {file_path}: {error}")
return report
async def get_db_file_state(self, db_records: Sequence[Entity]) -> Dict[str, FileState]:
"""Get file_path and checksums from database.
Args:
db_records: database records
Returns:
Dict mapping file paths to FileState
:param db_records: the data from the db
"""
return {
r.file_path: FileState(
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum or ""
)
for r in db_records
}
async def find_knowledge_changes(self, directory: Path) -> SyncReport:
"""Find changes in knowledge directory."""
db_file_state = await self.get_db_file_state(await self.entity_repository.find_all())
return await self.find_changes(directory=directory, db_file_state=db_file_state)
+291 -120
View File
@@ -1,47 +1,262 @@
"""Service for syncing files between filesystem and database."""
import os
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from pathlib import Path
from typing import Dict
from typing import Set, Dict
from typing import Tuple
import logfire
from loguru import logger
from sqlalchemy.exc import IntegrityError
from basic_memory import file_utils
from basic_memory.markdown import EntityParser, EntityMarkdown
from basic_memory.markdown import EntityParser
from basic_memory.models import Entity
from basic_memory.repository import EntityRepository, RelationRepository
from basic_memory.services import EntityService
from basic_memory.services import EntityService, FileService
from basic_memory.services.search_service import SearchService
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.utils import SyncReport
@dataclass
class SyncReport:
"""Report of file changes found compared to database state.
Attributes:
total: Total number of files in directory being synced
new: Files that exist on disk but not in database
modified: Files that exist in both but have different checksums
deleted: Files that exist in database but not on disk
moves: Files that have been moved from one location to another
checksums: Current checksums for files on disk
"""
# We keep paths as strings in sets/dicts for easier serialization
new: Set[str] = field(default_factory=set)
modified: Set[str] = field(default_factory=set)
deleted: Set[str] = field(default_factory=set)
moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path
checksums: Dict[str, str] = field(default_factory=dict) # path -> checksum
@property
def total(self) -> int:
"""Total number of changes."""
return len(self.new) + len(self.modified) + len(self.deleted) + len(self.moves)
@dataclass
class ScanResult:
"""Result of scanning a directory."""
# file_path -> checksum
files: Dict[str, str] = field(default_factory=dict)
# checksum -> file_path
checksums: Dict[str, str] = field(default_factory=dict)
# file_path -> error message
errors: Dict[str, str] = field(default_factory=dict)
class SyncService:
"""Syncs documents and knowledge files with database.
Implements two-pass sync strategy for knowledge files to handle relations:
1. First pass creates/updates entities without relations
2. Second pass processes relations after all entities exist
"""
"""Syncs documents and knowledge files with database."""
def __init__(
self,
scanner: FileChangeScanner,
entity_service: EntityService,
entity_parser: EntityParser,
entity_repository: EntityRepository,
relation_repository: RelationRepository,
search_service: SearchService,
file_service: FileService,
):
self.scanner = scanner
self.entity_service = entity_service
self.entity_parser = entity_parser
self.entity_repository = entity_repository
self.relation_repository = relation_repository
self.search_service = search_service
self.file_service = file_service
async def handle_entity_deletion(self, file_path: str):
async def sync(self, directory: Path) -> SyncReport:
"""Sync all files with database."""
with logfire.span(f"sync {directory}", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
# initial paths from db to sync
# path -> checksum
report = await self.scan(directory)
# order of sync matters to resolve relations effectively
# 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)
else:
await self.handle_move(old_path, new_path)
# deleted next
for path in report.deleted:
await self.handle_delete(path)
# then new and modified
for path in report.new:
await self.sync_file(path, new=True)
for path in report.modified:
await self.sync_file(path, new=False)
await self.resolve_relations()
return report
async def scan(self, directory):
"""Scan directory for changes compared to database state."""
db_paths = await self.get_db_file_state()
# Track potentially moved files by checksum
scan_result = await self.scan_directory(directory)
report = SyncReport()
# First find potential new files and record checksums
# if a path is not present in the db, it could be new or could be the destination of a move
for file_path, checksum in scan_result.files.items():
if file_path not in db_paths:
report.new.add(file_path)
report.checksums[file_path] = checksum
# Now detect moves and deletions
for db_path, db_checksum in db_paths.items():
local_checksum_for_db_path = scan_result.files.get(db_path)
# file not modified
if db_checksum == local_checksum_for_db_path:
pass
# if checksums don't match for the same path, its modified
if local_checksum_for_db_path and db_checksum != local_checksum_for_db_path:
report.modified.add(db_path)
report.checksums[db_path] = local_checksum_for_db_path
# check if it's moved or deleted
if not local_checksum_for_db_path:
# if we find the checksum in another file, it's a move
if db_checksum in scan_result.checksums:
new_path = scan_result.checksums[db_checksum]
report.moves[db_path] = new_path
# Remove from new files if present
if new_path in report.new:
report.new.remove(new_path)
# deleted
else:
report.deleted.add(db_path)
return report
async def get_db_file_state(self) -> Dict[str, str]:
"""Get file_path and checksums from database.
Args:
db_records: database records
Returns:
Dict mapping file paths to FileState
:param db_records: the data from the db
"""
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."""
try:
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)
return entity, checksum
except Exception as e: # pragma: no cover
logger.error(f"Failed to sync {path}: {e}")
raise
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
"""Sync a markdown file with full processing."""
# Parse markdown first to get any existing permalink
entity_markdown = await self.entity_parser.parse_file(path)
# Resolve permalink - this handles all the cases including conflicts
permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown)
# If permalink changed, update the file
if permalink != entity_markdown.frontmatter.permalink:
logger.info(f"Updating permalink in {path}: {permalink}")
entity_markdown.frontmatter.metadata["permalink"] = permalink
checksum = await self.file_service.update_frontmatter(path, {"permalink": permalink})
else:
checksum = await self.file_service.compute_checksum(path)
# if the file is new, create an entity
if new:
# Create entity with final permalink
logger.debug(f"Creating new entity from markdown: {path}")
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}")
await self.entity_service.update_entity_and_observations(Path(path), entity_markdown)
# Update relations and search index
entity = await self.entity_service.update_entity_relations(path, entity_markdown)
# set checksum
await self.entity_repository.update(entity.id, {"checksum": checksum})
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."""
checksum = await self.file_service.compute_checksum(path)
if new:
# Generate permalink from path
await self.entity_service.resolve_permalink(path)
# get file timestamps
file_stats = self.file_service.file_stats(path)
created = datetime.fromtimestamp(file_stats.st_ctime)
modified = datetime.fromtimestamp(file_stats.st_mtime)
# get mime type
content_type = self.file_service.content_type(path)
file_path = Path(path)
entity = await self.entity_repository.add(
Entity(
entity_type="file",
file_path=path,
checksum=checksum,
title=file_path.name,
created_at=created,
updated_at=modified,
content_type=content_type,
)
)
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"
updated = await self.entity_repository.update(
entity.id, {"file_path": path, "checksum": checksum}
)
assert updated is not None, "entity should be updated"
return updated, checksum
async def handle_delete(self, file_path: str):
"""Handle complete entity deletion including search index cleanup."""
# First get entity to get permalink before deletion
entity = await self.entity_repository.get_by_file_path(file_path)
if entity:
@@ -58,117 +273,73 @@ class SyncService:
)
logger.debug(f"Deleting from search index: {permalinks}")
for permalink in permalinks:
await self.search_service.delete_by_permalink(permalink)
async def sync(self, directory: Path) -> SyncReport:
"""Sync knowledge files with database."""
with logfire.span("sync", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
changes = await self.scanner.find_knowledge_changes(directory)
logger.info(f"Found {changes.total_changes} knowledge changes")
# Handle moves first
for old_path, new_path in changes.moves.items():
logger.debug(f"Moving entity: {old_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, "checksum": changes.checksums[new_path]}
)
# update search index
if updated:
await self.search_service.index_entity(updated)
# Handle deletions next
# remove rows from db for files no longer present
for path in changes.deleted:
await self.handle_entity_deletion(path)
# Parse files that need updating
parsed_entities: Dict[str, EntityMarkdown] = {}
for path in [*changes.new, *changes.modified]:
entity_markdown = await self.entity_parser.parse_file(directory / path)
parsed_entities[path] = entity_markdown
# First pass: Create/update entities
# entities will have a null checksum to indicate they are not complete
for path, entity_markdown in parsed_entities.items():
# Get unique permalink and update markdown if needed
permalink = await self.entity_service.resolve_permalink(
Path(path), markdown=entity_markdown
)
if permalink != entity_markdown.frontmatter.permalink:
# Add/update permalink in frontmatter
logger.info(f"Adding permalink '{permalink}' to file: {path}")
# update markdown
entity_markdown.frontmatter.metadata["permalink"] = permalink
# update file frontmatter
updated_checksum = await file_utils.update_frontmatter(
directory / path, {"permalink": permalink}
)
# Update checksum in changes report since file was modified
changes.checksums[path] = updated_checksum
# if the file is new, create an entity
if path in changes.new:
# Create entity with final permalink
logger.debug(f"Creating new entity_markdown: {path}")
await self.entity_service.create_entity_from_markdown(
Path(path), entity_markdown
)
# otherwise we need to update the entity and observations
if permalink:
await self.search_service.delete_by_permalink(permalink)
else:
logger.debug(f"Updating entity_markdown: {path}")
await self.entity_service.update_entity_and_observations(
Path(path), entity_markdown
)
await self.search_service.delete_by_entity_id(entity.id)
# Second pass
for path, entity_markdown in parsed_entities.items():
logger.debug(f"Updating relations for: {path}")
async def handle_move(self, old_path, new_path):
logger.debug(f"Moving entity: {old_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"
# update search index
await self.search_service.index_entity(updated)
# Process relations
checksum = changes.checksums[path]
entity = await self.entity_service.update_entity_relations(
Path(path), entity_markdown
async def resolve_relations(self):
"""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")
for relation in unresolved_relations:
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}"
)
await self.relation_repository.update(
relation.id,
{
"to_id": resolved_entity.id,
"to_name": resolved_entity.title,
},
)
# add to search index
await self.search_service.index_entity(entity)
# update search index
await self.search_service.index_entity(resolved_entity)
# Set final checksum to mark sync complete
await self.entity_repository.update(entity.id, {"checksum": checksum})
async def scan_directory(self, directory: Path) -> ScanResult:
"""
Scan directory for markdown files and their checksums.
# Third pass: Try to resolve any forward references
logger.debug("Attempting to resolve forward references")
for relation in await self.relation_repository.find_unresolved_relations():
target_entity = await self.entity_service.link_resolver.resolve_link(
relation.to_name
)
# check we found a link that is not the source
if target_entity and target_entity.id != relation.from_id:
logger.debug(
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
)
Args:
directory: Directory to scan
try:
await self.relation_repository.update(
relation.id,
{
"to_id": target_entity.id,
"to_name": target_entity.title, # Update to actual title
},
)
except IntegrityError:
logger.debug(f"Ignoring duplicate relation {relation}")
Returns:
ScanResult containing found files and any errors
"""
# update search index
await self.search_service.index_entity(target_entity)
logger.debug(f"Scanning directory: {directory}")
result = ScanResult()
return changes
for root, dirnames, filenames in os.walk(str(directory)):
# Skip dot directories in-place
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for filename in filenames:
# Skip dot files
if filename.startswith("."):
continue
path = Path(root) / filename
rel_path = str(path.relative_to(directory))
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}")
return result
-31
View File
@@ -1,31 +0,0 @@
"""Types and utilities for file sync."""
from dataclasses import dataclass, field
from typing import Set, Dict
@dataclass
class SyncReport:
"""Report of file changes found compared to database state.
Attributes:
total: Total number of files in directory being synced
new: Files that exist on disk but not in database
modified: Files that exist in both but have different checksums
deleted: Files that exist in database but not on disk
moves: Files that have been moved from one location to another
checksums: Current checksums for files on disk
"""
total: int = 0
# We keep paths as strings in sets/dicts for easier serialization
new: Set[str] = field(default_factory=set)
modified: Set[str] = field(default_factory=set)
deleted: Set[str] = field(default_factory=set)
moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path
checksums: Dict[str, str] = field(default_factory=dict) # path -> checksum
@property
def total_changes(self) -> int:
"""Total number of changes."""
return len(self.new) + len(self.modified) + len(self.deleted) + len(self.moves)
+122 -126
View File
@@ -1,22 +1,20 @@
"""Watch service for Basic Memory."""
import dataclasses
import os
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Set
from loguru import logger
from pydantic import BaseModel
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from rich.console import Console
from rich.live import Live
from rich.table import Table
from watchfiles import awatch, Change
import os
from watchfiles import awatch
from watchfiles.main import FileChange, Change
from basic_memory.config import ProjectConfig
from basic_memory.sync.sync_service import SyncService
from basic_memory.services.file_service import FileService
from basic_memory.sync.sync_service import SyncService
class WatchEvent(BaseModel):
@@ -81,138 +79,136 @@ class WatchService:
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self.console = Console()
def generate_table(self) -> Table:
"""Generate status display table"""
table = Table()
# Add status row
table.add_column("Status", style="cyan")
table.add_column("Last Scan", style="cyan")
table.add_column("Files", style="cyan")
table.add_column("Errors", style="red")
# Add main status row
table.add_row(
"✓ Running" if self.state.running else "✗ Stopped",
self.state.last_scan.strftime("%H:%M:%S") if self.state.last_scan else "-",
str(self.state.synced_files),
f"{self.state.error_count} ({self.state.last_error.strftime('%H:%M:%S') if self.state.last_error else 'none'})",
)
if self.state.recent_events:
# Add recent events
table.add_section()
table.add_row("Recent Events", "", "", "")
for event in self.state.recent_events[:5]: # Show last 5 events
color = {
"new": "green",
"modified": "yellow",
"moved": "blue",
"deleted": "red",
"error": "red",
}.get(event.action, "white")
icon = {
"new": "",
"modified": "",
"moved": "",
"deleted": "",
"error": "!",
}.get(event.action, "*")
table.add_row(
f"[{color}]{icon} {event.action}[/{color}]",
event.timestamp.strftime("%H:%M:%S"),
f"[{color}]{event.path}[/{color}]",
f"[dim]{event.checksum[:8] if event.checksum else ''}[/dim]",
)
return table
async def run(self, console_status: bool = False): # pragma: no cover
async def run(self): # pragma: no cover
"""Watch for file changes and sync them"""
logger.info("Watching for sync changes")
self.state.running = True
self.state.start_time = datetime.now()
await self.write_status()
try:
async for changes in awatch(
self.config.home,
debounce=self.config.sync_delay,
watch_filter=self.filter_changes,
recursive=True,
):
await self.handle_changes(self.config.home, changes)
if console_status:
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
try:
async for changes in awatch(
self.config.home,
watch_filter=self.filter_changes,
debounce=self.config.sync_delay,
recursive=True,
):
# Process changes
await self.handle_changes(self.config.home)
# Update display
live.update(self.generate_table())
except Exception as e:
self.state.record_error(str(e))
await self.write_status()
raise
finally:
self.state.running = False
await self.write_status()
except Exception as e:
self.state.record_error(str(e))
await self.write_status()
raise
finally:
self.state.running = False
await self.write_status()
def filter_changes(self, change: Change, path: str) -> bool:
"""Filter to only watch non-hidden files and directories.
else:
try:
async for changes in awatch(
self.config.home,
watch_filter=self.filter_changes,
debounce=self.config.sync_delay,
recursive=True,
):
# Process changes
await self.handle_changes(self.config.home)
# Update display
Returns:
True if the file should be watched, False if it should be ignored
"""
# Skip if path is invalid
try:
relative_path = Path(path).relative_to(self.config.home)
except ValueError:
return False
except Exception as e:
self.state.record_error(str(e))
await self.write_status()
raise
finally:
self.state.running = False
await self.write_status()
# Skip hidden directories and files
path_parts = relative_path.parts
for part in path_parts:
if part.startswith("."):
return False
return True
async def write_status(self):
"""Write current state to status file"""
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
def filter_changes(self, change: Change, path: str) -> bool:
"""Filter to only watch markdown files"""
return path.endswith(".md") and not Path(path).name.startswith(".")
async def handle_changes(self, directory: Path):
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} ...")
# Group changes by type
adds = []
deletes = []
modifies = []
for change, path in changes:
# convert to relative path
relative_path = str(Path(path).relative_to(directory))
if change == Change.added:
adds.append(relative_path)
elif change == Change.deleted:
deletes.append(relative_path)
elif change == Change.modified:
modifies.append(relative_path)
# Track processed files to avoid duplicates
processed = set()
# First handle potential moves
for added_path in adds:
if added_path in processed:
continue # pragma: no cover
for deleted_path in deletes:
if deleted_path in processed:
continue # pragma: no cover
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)
self.state.add_event(
path=f"{deleted_path} -> {added_path}",
action="moved",
status="success",
)
self.console.print(
f"[blue]→[/blue] Moved: {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}")
# Handle remaining changes
for path in deletes:
if path not in processed:
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}")
processed.add(path)
for path in adds:
if path not in processed:
_, checksum = await self.sync_service.sync_file(path, new=True)
self.state.add_event(path=path, action="new", status="success", checksum=checksum)
self.console.print(f"[green]✓[/green] Added: {path}")
processed.add(path)
for path in modifies:
if path not in processed:
_, 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}")
processed.add(path)
# Add a divider if we processed any files
if processed:
self.console.print("" * 50, style="dim")
logger.debug(f"handling change in directory: {directory} ...")
# Process changes with timeout
report = await self.sync_service.sync(directory)
self.state.last_scan = datetime.now()
self.state.synced_files = report.total
# Update stats
for path in report.new:
self.state.add_event(
path=path, action="new", status="success", checksum=report.checksums[path]
)
for path in report.modified:
self.state.add_event(
path=path, action="modified", status="success", checksum=report.checksums[path]
)
for old_path, new_path in report.moves.items():
self.state.add_event(
path=f"{old_path} -> {new_path}",
action="moved",
status="success",
checksum=report.checksums[new_path],
)
for path in report.deleted:
self.state.add_event(path=path, action="deleted", status="success")
self.state.synced_files += len(processed)
await self.write_status()
+24 -9
View File
@@ -1,5 +1,6 @@
"""Utility functions for basic-memory."""
import logging
import os
import re
import sys
@@ -10,7 +11,6 @@ from loguru import logger
from unidecode import unidecode
import basic_memory
from basic_memory.config import config
import logfire
@@ -65,7 +65,11 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
def setup_logging(
home_dir: Path = config.home, log_file: Optional[str] = None, console: bool = True
env: str,
home_dir: Path,
log_file: Optional[str] = None,
log_level: str = "INFO",
console: bool = True,
) -> None: # pragma: no cover
"""
Configure logging for the application.
@@ -79,15 +83,14 @@ def setup_logging(
logger.remove()
# Add file handler if we are not running tests
if log_file and config.env != "test":
if log_file and env != "test":
# enable pydantic logfire
logfire.configure(
code_source=logfire.CodeSource(
repository="https://github.com/basicmachines-co/basic-memory",
revision=basic_memory.__version__,
root_path="/src/basic_memory",
),
environment=config.env,
environment=env,
console=False,
)
logger.configure(handlers=[logfire.loguru_handler()])
@@ -100,7 +103,7 @@ def setup_logging(
log_path = home_dir / log_file
logger.add(
str(log_path),
level=config.log_level,
level=log_level,
rotation="100 MB",
retention="10 days",
backtrace=True,
@@ -109,7 +112,19 @@ def setup_logging(
colorize=False,
)
# Add stderr handler
logger.add(sys.stderr, level=config.log_level, backtrace=True, diagnose=True, colorize=True)
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: '{config.env}' Log level: '{config.log_level}' Logging to {log_file}")
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)
# turn watchfiles to WARNING
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
# disable open telemetry warning
logging.getLogger("instrumentor").setLevel(logging.ERROR)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+2 -2
View File
@@ -1,4 +1,4 @@
from basic_memory.config import config
import os
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
config.env = "test"
os.environ["BASIC_MEMORY_ENV"] = "test"
+124
View File
@@ -1,5 +1,6 @@
"""Tests for resource router endpoints."""
import json
from datetime import datetime, timezone
import pytest
@@ -303,3 +304,126 @@ async def test_get_resource_relation(client, test_config, entity_repository):
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_put_resource_new_file(client, test_config, entity_repository, search_repository):
"""Test creating a new file via PUT."""
# Test data
file_path = "visualizations/test.canvas"
canvas_data = {
"nodes": [
{
"id": "node1",
"type": "text",
"text": "Test node content",
"x": 100,
"y": 200,
"width": 400,
"height": 300,
}
],
"edges": [],
}
# Make sure the file doesn't exist yet
full_path = Path(test_config.home) / file_path
if full_path.exists():
full_path.unlink()
# Execute PUT request
response = await client.put(f"/resource/{file_path}", json=json.dumps(canvas_data, indent=2))
# Verify response
assert response.status_code == 201
response_data = response.json()
assert response_data["file_path"] == file_path
assert "checksum" in response_data
assert "size" in response_data
# Verify file was created
full_path = Path(test_config.home) / file_path
assert full_path.exists()
# Verify file content
file_content = full_path.read_text()
assert json.loads(file_content) == canvas_data
# Verify entity was created in DB
entity = await entity_repository.get_by_file_path(file_path)
assert entity is not None
assert entity.entity_type == "canvas"
assert entity.content_type == "application/json"
# Verify entity was indexed for search
search_results = await search_repository.search(title="test.canvas")
assert len(search_results) > 0
@pytest.mark.asyncio
async def test_put_resource_update_existing(client, test_config, entity_repository):
"""Test updating an existing file via PUT."""
# Create an initial file and entity
file_path = "visualizations/update-test.canvas"
full_path = Path(test_config.home) / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
initial_data = {
"nodes": [
{
"id": "initial",
"type": "text",
"text": "Initial content",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
}
],
"edges": [],
}
full_path.write_text(json.dumps(initial_data))
# Create the initial entity
initial_entity = await entity_repository.create(
{
"title": "update-test.canvas",
"entity_type": "canvas",
"file_path": file_path,
"content_type": "application/json",
"checksum": "initial123",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
# New data for update
updated_data = {
"nodes": [
{
"id": "updated",
"type": "text",
"text": "Updated content",
"x": 100,
"y": 100,
"width": 300,
"height": 200,
}
],
"edges": [],
}
# Execute PUT request to update
response = await client.put(f"/resource/{file_path}", json=json.dumps(updated_data, indent=2))
# Verify response
assert response.status_code == 200
# Verify file was updated
updated_content = full_path.read_text()
assert json.loads(updated_content) == updated_data
# Verify entity was updated
updated_entity = await entity_repository.get_by_file_path(file_path)
assert updated_entity.id == initial_entity.id # Same entity, updated
assert updated_entity.checksum != initial_entity.checksum # Checksum changed
+236 -163
View File
@@ -1,126 +1,115 @@
"""Tests for the Basic Memory CLI tools."""
"""Tests for the Basic Memory CLI tools.
These tests use real MCP tools with the test environment instead of mocks.
"""
from datetime import datetime, timedelta
import json
from textwrap import dedent
from typing import AsyncGenerator
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from typer.testing import CliRunner
from unittest.mock import patch, AsyncMock
from basic_memory.cli.commands.tools import tool_app
from basic_memory.schemas.response import EntityResponse
from basic_memory.schemas.search import SearchResponse
from basic_memory.schemas.memory import GraphContext
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
runner = CliRunner()
@pytest.fixture
def mock_write_note():
with patch("basic_memory.cli.commands.tools.mcp_write_note", new_callable=AsyncMock) as mock:
mock.return_value = "Created test/note.md (abc123)\npermalink: test/note"
yield mock
@pytest_asyncio.fixture
def app(test_config, engine_factory) -> FastAPI:
"""Create test FastAPI application."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
@pytest.fixture
def mock_read_note():
with patch("basic_memory.cli.commands.tools.mcp_read_note", new_callable=AsyncMock) as mock:
mock.return_value = "--- memory://test/note 2025-01 abc123\nTest content"
yield mock
def cli_env(test_config, client):
pass
@pytest.fixture
def mock_search():
with patch("basic_memory.cli.commands.tools.mcp_search", new_callable=AsyncMock) as mock:
mock.return_value = SearchResponse(results=[], current_page=1, page_size=10)
yield mock
@pytest_asyncio.fixture
async def setup_test_note(entity_service, search_service) -> AsyncGenerator[dict, None]:
"""Create a test note for CLI tests."""
note_content = dedent("""
# Test Note
This is a test note for CLI commands.
## Observations
- [tech] Test observation #test
- [note] Another observation
## Relations
- connects_to [[Another Note]]
""")
@pytest.fixture
def mock_build_context():
with patch("basic_memory.cli.commands.tools.mcp_build_context", new_callable=AsyncMock) as mock:
now = datetime.now(timezone.utc)
mock.return_value = GraphContext(
primary_results=[],
related_results=[],
metadata={
"uri": "test/*",
"depth": 1,
"timeframe": "7d",
"generated_at": now,
"total_results": 0,
"total_relations": 0,
},
)
yield mock
@pytest.fixture
def mock_recent_activity():
with patch(
"basic_memory.cli.commands.tools.mcp_recent_activity", new_callable=AsyncMock
) as mock:
now = datetime.now(timezone.utc)
mock.return_value = GraphContext(
primary_results=[],
related_results=[],
metadata={
"uri": None,
"types": ["entity", "observation"],
"depth": 1,
"timeframe": "7d",
"generated_at": now,
"total_results": 0,
"total_relations": 0,
},
)
yield mock
@pytest.fixture
def mock_get_entity():
with patch("basic_memory.cli.commands.tools.mcp_get_entity", new_callable=AsyncMock) as mock:
now = datetime.now(timezone.utc)
mock.return_value = EntityResponse(
permalink="test/entity",
title="Test Entity",
file_path="test/entity.md",
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Test Note",
folder="test",
entity_type="note",
content_type="text/markdown",
observations=[],
relations=[],
created_at=now,
updated_at=now,
content=note_content,
)
yield mock
)
# Index the entity for search
await search_service.index_entity(entity)
yield {
"title": entity.title,
"permalink": entity.permalink,
"content": note_content,
}
def test_write_note(mock_write_note):
def test_write_note(cli_env, test_config):
"""Test write_note command with basic arguments."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Test Note",
"CLI Test Note",
"--content",
"Test content",
"This is a CLI test note",
"--folder",
"test",
],
)
assert result.exit_code == 0
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", None)
# Check for expected success message
assert "CLI Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
assert "permalink" in result.stdout
def test_write_note_with_tags(mock_write_note):
def test_write_note_with_tags(cli_env, test_config):
"""Test write_note command with tags."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Test Note",
"Tagged CLI Test Note",
"--content",
"Test content",
"This is a test note with tags",
"--folder",
"test",
"--tags",
@@ -130,103 +119,119 @@ def test_write_note_with_tags(mock_write_note):
],
)
assert result.exit_code == 0
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", ["tag1", "tag2"])
# Check for expected success message
assert "Tagged CLI Test Note" in result.stdout
assert "tag1, tag2" in result.stdout or "tag1" in result.stdout and "tag2" in result.stdout
def test_read_note(mock_read_note):
def test_read_note(cli_env, setup_test_note):
"""Test read_note command."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["read-note", "test/note"],
["read-note", permalink],
)
assert result.exit_code == 0
mock_read_note.assert_awaited_once_with("test/note", 1, 10)
# Should contain the note content and structure
assert "Test Note" in result.stdout
assert "This is a test note for CLI commands" in result.stdout
assert "## Observations" in result.stdout
assert "Test observation" in result.stdout
assert "## Relations" in result.stdout
assert "connects_to [[Another Note]]" in result.stdout
# Note: We found that square brackets like [tech] are being stripped in CLI output,
# so we're not asserting their presence
def test_read_note_with_pagination(mock_read_note):
"""Test read_note command with pagination."""
result = runner.invoke(
tool_app,
["read-note", "test/note", "--page", "2", "--page-size", "5"],
)
assert result.exit_code == 0
mock_read_note.assert_awaited_once_with("test/note", 2, 5)
def test_search_basic(mock_search):
def test_search_basic(cli_env, setup_test_note):
"""Test basic search command."""
result = runner.invoke(
tool_app,
["search", "test query"],
["search", "test observation"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].text == "test query"
# Result should be JSON containing our test note
search_result = json.loads(result.stdout)
assert len(search_result["results"]) > 0
# At least one result should match our test note or observation
found = False
for item in search_result["results"]:
if "test" in item["permalink"].lower() and "observation" in item["permalink"].lower():
found = True
break
assert found, "Search did not find the test observation"
def test_search_permalink(mock_search):
def test_search_permalink(cli_env, setup_test_note):
"""Test search with permalink flag."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["search", "test/*", "--permalink"],
["search", permalink, "--permalink"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].permalink_match == "test/*"
# Result should be JSON containing our test note
search_result = json.loads(result.stdout)
assert len(search_result["results"]) > 0
# Should find a result with matching permalink
found = False
for item in search_result["results"]:
if item["permalink"] == permalink:
found = True
break
assert found, "Search did not find the note by permalink"
def test_search_title(mock_search):
"""Test search with title flag."""
result = runner.invoke(
tool_app,
["search", "test", "--title"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].title == "test"
def test_search_with_pagination(mock_search):
"""Test search with pagination."""
result = runner.invoke(
tool_app,
["search", "test", "--page", "2", "--page-size", "5"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["page"] == 2
assert args["page_size"] == 5
def test_build_context(mock_build_context):
def test_build_context(cli_env, setup_test_note):
"""Test build_context command."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["build-context", "memory://test/*"],
["build-context", f"memory://{permalink}"],
)
assert result.exit_code == 0
mock_build_context.assert_awaited_once_with(
url="memory://test/*", depth=1, timeframe="7d", page=1, page_size=10, max_related=10
)
# Result should be JSON containing our test note
context_result = json.loads(result.stdout)
assert len(context_result["primary_results"]) > 0
# Primary results should include our test note
found = False
for item in context_result["primary_results"]:
if item["permalink"] == permalink:
found = True
break
assert found, "Context did not include the test note"
def test_build_context_with_options(mock_build_context):
def test_build_context_with_options(cli_env, setup_test_note):
"""Test build_context command with all options."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
[
"build-context",
"memory://test/*",
f"memory://{permalink}",
"--depth",
"2",
"--timeframe",
"1d",
"--page",
"2",
"1",
"--page-size",
"5",
"--max-related",
@@ -234,39 +239,67 @@ def test_build_context_with_options(mock_build_context):
],
)
assert result.exit_code == 0
mock_build_context.assert_awaited_once_with(
url="memory://test/*", depth=2, timeframe="1d", page=2, page_size=5, max_related=20
)
# Result should be JSON containing our test note
context_result = json.loads(result.stdout)
# Check that metadata reflects our options
assert context_result["metadata"]["depth"] == 2
timeframe = datetime.fromisoformat(context_result["metadata"]["timeframe"])
assert datetime.now() - timeframe <= timedelta(days=2) # don't bother about timezones
# Primary results should include our test note
found = False
for item in context_result["primary_results"]:
if item["permalink"] == permalink:
found = True
break
assert found, "Context did not include the test note"
def test_get_entity(mock_get_entity):
def test_get_entity(cli_env, setup_test_note):
"""Test get_entity command."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["get-entity", "test/entity"],
["get-entity", permalink],
)
assert result.exit_code == 0
mock_get_entity.assert_awaited_once_with(identifier="test/entity")
# Result should be JSON containing our entity
entity_result = json.loads(result.stdout)
assert entity_result["permalink"] == permalink
assert entity_result["title"] == "Test Note"
assert len(entity_result["observations"]) >= 2
assert len(entity_result["relations"]) >= 1
def test_recent_activity(mock_recent_activity):
def test_recent_activity(cli_env, setup_test_note):
"""Test recent_activity command with defaults."""
result = runner.invoke(
tool_app,
["recent-activity"],
)
assert result.exit_code == 0
mock_recent_activity.assert_awaited_once_with(
type=["entity", "observation", "relation"],
depth=1,
timeframe="7d",
page=1,
page_size=10,
max_related=10,
)
# Result should be JSON containing recent activity
activity_result = json.loads(result.stdout)
assert "primary_results" in activity_result
assert "metadata" in activity_result
# Our test note should be in the recent activity
found = False
for item in activity_result["primary_results"]:
if "permalink" in item and setup_test_note["permalink"] == item["permalink"]:
found = True
break
assert found, "Recent activity did not include the test note"
def test_recent_activity_with_options(mock_recent_activity):
def test_recent_activity_with_options(cli_env, setup_test_note):
"""Test recent_activity command with options."""
result = runner.invoke(
tool_app,
@@ -274,21 +307,61 @@ def test_recent_activity_with_options(mock_recent_activity):
"recent-activity",
"--type",
"entity",
"--type",
"observation",
"--depth",
"2",
"--timeframe",
"1d",
"7d",
"--page",
"2",
"1",
"--page-size",
"5",
"20",
"--max-related",
"20",
],
)
assert result.exit_code == 0
mock_recent_activity.assert_awaited_once_with(
type=["entity", "observation"], depth=2, timeframe="1d", page=2, page_size=5, max_related=20
# Result should be JSON containing recent activity
activity_result = json.loads(result.stdout)
# Check that requested entity types are included
entity_types = set()
for item in activity_result["primary_results"]:
if "type" in item:
entity_types.add(item["type"])
# Should find both entity and observation types
assert "entity" in entity_types or "observation" in entity_types
def test_continue_conversation(cli_env, setup_test_note):
"""Test continue_conversation command."""
permalink = setup_test_note["permalink"]
# Run the CLI command
result = runner.invoke(
tool_app,
["continue-conversation", "--topic", "Test Note"],
)
assert result.exit_code == 0
# Check result contains expected content
assert "Continuing conversation on: Test Note" in result.stdout
assert "This is a memory retrieval session" in result.stdout
assert "read_note" in result.stdout
assert permalink in result.stdout
def test_continue_conversation_no_results(cli_env):
"""Test continue_conversation command with no results."""
# Run the CLI command with a nonexistent topic
result = runner.invoke(
tool_app,
["continue-conversation", "--topic", "NonexistentTopic"],
)
assert result.exit_code == 0
# 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
+8 -39
View File
@@ -1,7 +1,6 @@
"""Tests for CLI status command."""
import pytest
import pytest_asyncio
from typer.testing import CliRunner
from basic_memory.cli.app import app
@@ -9,51 +8,21 @@ from basic_memory.cli.commands.status import (
add_files_to_tree,
build_directory_summary,
group_changes_by_directory,
run_status,
display_changes,
)
from basic_memory.sync.utils import SyncReport
from basic_memory.sync import FileChangeScanner
from basic_memory.repository import EntityRepository
from basic_memory.config import config
from basic_memory.sync.sync_service import SyncReport
# Set up CLI runner
runner = CliRunner()
@pytest_asyncio.fixture
async def file_change_scanner(session_maker):
"""Create FileChangeScanner instance with test database."""
entity_repository = EntityRepository(session_maker)
scanner = FileChangeScanner(entity_repository)
return scanner
@pytest.mark.asyncio
async def test_run_status_no_changes(file_change_scanner, tmp_path, monkeypatch):
"""Test status command with no changes."""
# Set up test environment
monkeypatch.setenv("HOME", str(tmp_path))
knowledge_dir = tmp_path / "knowledge"
knowledge_dir.mkdir()
# Run status check
await run_status(file_change_scanner, verbose=False)
@pytest.mark.asyncio
async def test_run_status_with_changes(file_change_scanner, tmp_path, monkeypatch):
"""Test status command with actual file changes."""
# Set up test environment
monkeypatch.setenv("HOME", str(tmp_path))
knowledge_dir = tmp_path / "knowledge"
knowledge_dir.mkdir()
# Create test files
test_file = knowledge_dir / "test.md"
test_file.write_text("# Test\nSome content")
# Run status check - should detect new file
await run_status(file_change_scanner, verbose=True)
def test_status_command(tmp_path, monkeypatch):
"""Test CLI status command."""
config.home = tmp_path
# Should exit with code 0
result = runner.invoke(app, ["status", "--verbose"])
assert result.exit_code == 0
@pytest.mark.asyncio
+2 -1
View File
@@ -1,6 +1,7 @@
"""Tests for CLI sync command."""
import asyncio
import pytest
from typer.testing import CliRunner
@@ -13,7 +14,7 @@ from basic_memory.cli.commands.sync import (
ValidationIssue,
)
from basic_memory.config import config
from basic_memory.sync.utils import SyncReport
from basic_memory.sync.sync_service import SyncReport
# Set up CLI runner
runner = CliRunner()
+39 -9
View File
@@ -1,5 +1,6 @@
"""Common test fixtures."""
from pathlib import Path
from textwrap import dedent
from typing import AsyncGenerator
from datetime import datetime, timezone
@@ -27,7 +28,6 @@ from basic_memory.services import (
from basic_memory.services.file_service import FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.sync_service import SyncService
from basic_memory.sync.watch_service import WatchService
@@ -138,29 +138,23 @@ def entity_parser(test_config):
return EntityParser(test_config.home)
@pytest_asyncio.fixture
def file_change_scanner(entity_repository) -> FileChangeScanner:
"""Create FileChangeScanner instance."""
return FileChangeScanner(entity_repository)
@pytest_asyncio.fixture
async def sync_service(
file_change_scanner: FileChangeScanner,
entity_service: EntityService,
entity_parser: EntityParser,
entity_repository: EntityRepository,
relation_repository: RelationRepository,
search_service: SearchService,
file_service: FileService,
) -> SyncService:
"""Create sync service for testing."""
return SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_repository=entity_repository,
relation_repository=relation_repository,
entity_parser=entity_parser,
search_service=search_service,
file_service=file_service,
)
@@ -321,3 +315,39 @@ async def test_graph(
@pytest_asyncio.fixture
def watch_service(sync_service, file_service, test_config):
return WatchService(sync_service=sync_service, file_service=file_service, config=test_config)
@pytest.fixture
def test_files(test_config) -> dict[str, Path]:
"""Copy test files into the project directory.
Returns a dict mapping file names to their paths in the project dir.
"""
# Source files relative to tests directory
source_files = {
"pdf": Path("tests/Non-MarkdownFileSupport.pdf"),
"image": Path("tests/Screenshot.png"),
}
# Create copies in temp project directory
project_files = {}
for name, src_path in source_files.items():
# Read source file
content = src_path.read_bytes()
# Create destination path and ensure parent dirs exist
dest_path = test_config.home / src_path.name
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
dest_path.write_bytes(content)
project_files[name] = dest_path
return project_files
@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)
return test_files
+61
View File
@@ -0,0 +1,61 @@
"""Tests for MCP prompts."""
import pytest
from basic_memory.mcp.prompts.continue_conversation import continue_conversation
@pytest.mark.asyncio
async def test_continue_conversation_with_topic(client, test_graph):
"""Test continue_conversation with a topic."""
# We can use the test_graph fixture which already has relevant content
# Call the function with a topic that should match existing content
result = await continue_conversation(topic="Root", timeframe="1w")
# Check that the result contains expected content
assert "Continuing conversation on: Root" in result
assert "This is a memory retrieval session" in result
assert "Start by executing one of the suggested commands" in result
assert "read_note" in result
@pytest.mark.asyncio
async def test_continue_conversation_with_recent_activity(client, test_graph):
"""Test continue_conversation with no topic, using recent activity."""
# Call the function without a topic
result = await continue_conversation(timeframe="1w")
# Check that the result contains expected content for recent activity
assert "Continuing conversation on: Recent Activity" in result
assert "This is a memory retrieval session" in result
assert "Please use the available basic-memory tools" in result
assert "Next Steps" in result
@pytest.mark.asyncio
async def test_continue_conversation_no_results(client):
"""Test continue_conversation when no results are found."""
# Call with a non-existent topic
result = await continue_conversation(topic="NonExistentTopic", timeframe="1w")
# 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
@pytest.mark.asyncio
async def test_continue_conversation_creates_structured_suggestions(client, test_graph):
"""Test that continue_conversation generates structured tool usage suggestions."""
# Call the function with a topic that should match existing content
result = await continue_conversation(topic="Root", timeframe="1w")
# Verify the response includes clear tool usage instructions
assert "start by executing one of the suggested commands" in result.lower()
# Check that the response contains tool call examples
assert "read_note" in result
assert "search" in result
assert "recent_activity" in result
assert "build_context" in result
+37
View File
@@ -0,0 +1,37 @@
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."""
# Call the resource function
guide = ai_assistant_guide()
# Verify basic characteristics of the content
assert guide is not None
assert isinstance(guide, str)
assert len(guide) > 0
# Verify it contains expected sections of the Canvas spec
assert "# AI Assistant Guide" in guide
+273
View File
@@ -0,0 +1,273 @@
"""Tests for canvas tool that exercise the full stack with SQLite."""
import json
from pathlib import Path
import pytest
from basic_memory.mcp.tools import canvas
@pytest.mark.asyncio
async def test_create_canvas(app, test_config):
"""Test creating a new canvas file.
Should:
- Create canvas file with correct content
- Create entity in database
- Return successful status
"""
# Test data
nodes = [
{
"id": "node1",
"type": "text",
"text": "Test Node",
"x": 100,
"y": 200,
"width": 400,
"height": 300,
}
]
edges = [{"id": "edge1", "fromNode": "node1", "toNode": "node2", "label": "connects to"}]
title = "test-canvas"
folder = "visualizations"
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify result message
assert result
assert "Created: visualizations/test-canvas" in result
assert "The canvas is ready to open in Obsidian" in result
# Verify file was created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Verify content is correct
content = json.loads(file_path.read_text())
assert content["nodes"] == nodes
assert content["edges"] == edges
@pytest.mark.asyncio
async def test_create_canvas_with_extension(app, test_config):
"""Test creating a canvas file with .canvas extension already in the title."""
# Test data
nodes = [
{
"id": "node1",
"type": "text",
"text": "Extension Test",
"x": 100,
"y": 200,
"width": 400,
"height": 300,
}
]
edges = []
title = "extension-test.canvas" # Already has extension
folder = "visualizations"
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/extension-test.canvas" in result
# Verify file exists with correct name (shouldn't have double extension)
file_path = Path(test_config.home) / folder / title
assert file_path.exists()
# Verify content
content = json.loads(file_path.read_text())
assert content["nodes"] == nodes
@pytest.mark.asyncio
async def test_update_existing_canvas(app, test_config):
"""Test updating an existing canvas file."""
# First create a canvas
nodes = [
{
"id": "initial",
"type": "text",
"text": "Initial content",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
}
]
edges = []
title = "update-test"
folder = "visualizations"
# Create initial canvas
await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify file exists
file_path = Path(test_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Now update with new content
updated_nodes = [
{
"id": "updated",
"type": "text",
"text": "Updated content",
"x": 100,
"y": 100,
"width": 300,
"height": 200,
}
]
updated_edges = [
{"id": "new-edge", "fromNode": "updated", "toNode": "other", "label": "new connection"}
]
# Execute update
result = await canvas(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder)
# Verify result indicates update
assert "Updated: visualizations/update-test.canvas" in result
# Verify content was updated
content = json.loads(file_path.read_text())
assert content["nodes"] == updated_nodes
assert content["edges"] == updated_edges
@pytest.mark.asyncio
async def test_create_canvas_with_nested_folders(app, test_config):
"""Test creating a canvas in nested folders that don't exist yet."""
# Test data
nodes = [
{
"id": "test",
"type": "text",
"text": "Nested folder test",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
}
]
edges = []
title = "nested-test"
folder = "visualizations/nested/folders" # Deep path
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/nested/folders/nested-test.canvas" in result
# Verify folders and file were created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
assert file_path.parent.exists()
@pytest.mark.asyncio
async def test_create_canvas_complex_content(app, test_config):
"""Test creating a canvas with complex content structures."""
# Test data - more complex structure with all node types
nodes = [
{
"id": "text-node",
"type": "text",
"text": "# Heading\n\nThis is a test with *markdown* formatting",
"x": 100,
"y": 100,
"width": 400,
"height": 300,
"color": "4", # Using a preset color
},
{
"id": "file-node",
"type": "file",
"file": "test/test-file.md", # Reference a file
"x": 600,
"y": 100,
"width": 400,
"height": 300,
"color": "#FF5500", # Using hex color
},
{
"id": "link-node",
"type": "link",
"url": "https://example.com",
"x": 100,
"y": 500,
"width": 400,
"height": 200,
},
{
"id": "group-node",
"type": "group",
"label": "Group Label",
"x": 600,
"y": 500,
"width": 600,
"height": 400,
},
]
edges = [
{
"id": "edge1",
"fromNode": "text-node",
"toNode": "file-node",
"label": "references",
"fromSide": "right",
"toSide": "left",
},
{
"id": "edge2",
"fromNode": "link-node",
"toNode": "group-node",
"label": "belongs to",
"color": "6",
},
]
title = "complex-test"
folder = "visualizations"
# Create a test file that we're referencing
test_file_path = Path(test_config.home) / "test/test-file.md"
test_file_path.parent.mkdir(parents=True, exist_ok=True)
test_file_path.write_text("# Test File\nThis is referenced by the canvas")
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/complex-test.canvas" in result
# Verify file was created
file_path = Path(test_config.home) / folder / f"{title}.canvas"
assert file_path.exists()
# Verify content is correct with all complex structures
content = json.loads(file_path.read_text())
assert len(content["nodes"]) == 4
assert len(content["edges"]) == 2
# Verify specific content elements are preserved
assert any(node["type"] == "text" and "#" in node["text"] for node in content["nodes"])
assert any(
node["type"] == "file" and "test-file.md" in node["file"] for node in content["nodes"]
)
assert any(node["type"] == "link" and "example.com" in node["url"] for node in content["nodes"])
assert any(
node["type"] == "group" and "Group Label" == node["label"] for node in content["nodes"]
)
# Verify edge properties
assert any(
edge["fromSide"] == "right" and edge["toSide"] == "left" for edge in content["edges"]
)
assert any(edge["label"] == "belongs to" and edge["color"] == "6" for edge in content["edges"])
+228
View File
@@ -0,0 +1,228 @@
"""Tests for resource tools that exercise the full stack with SQLite."""
import io
import base64
from PIL import Image as PILImage
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import resource
from basic_memory.mcp.tools import notes
@pytest.mark.asyncio
async def test_read_resource_text_file(app, synced_files):
"""Test reading a text file.
Should:
- Correctly identify text content
- Return the content as text
- Include correct metadata
"""
# First create a text file via notes
result = await notes.write_note(
title="Text Resource",
folder="test",
content="This is a test text resource",
tags=["test", "resource"],
)
assert result is not None
# Now read it as a resource
response = await resource.read_resource("test/text-resource")
assert response["type"] == "text"
assert "This is a test text resource" in response["text"]
assert response["content_type"].startswith("text/")
assert response["encoding"] == "utf-8"
@pytest.mark.asyncio
async def test_read_resource_image_file(app, synced_files):
"""Test reading an image file.
Should:
- Correctly identify image content
- Optimize the image
- Return base64 encoded image data
"""
# Get the path to the synced image file
image_path = synced_files["image"].name
# Read it as a resource
response = await resource.read_resource(image_path)
assert response["type"] == "image"
assert response["source"]["type"] == "base64"
assert response["source"]["media_type"] == "image/jpeg"
# Verify the image data is valid base64 that can be decoded
img_data = base64.b64decode(response["source"]["data"])
assert len(img_data) > 0
# Should be able to open as an image
img = PILImage.open(io.BytesIO(img_data))
assert img.width > 0
assert img.height > 0
@pytest.mark.asyncio
async def test_read_resource_pdf_file(app, synced_files):
"""Test reading a PDF file.
Should:
- Correctly identify PDF content
- Return base64 encoded PDF data
"""
# Get the path to the synced PDF file
pdf_path = synced_files["pdf"].name
# Read it as a resource
response = await resource.read_resource(pdf_path)
assert response["type"] == "document"
assert response["source"]["type"] == "base64"
assert response["source"]["media_type"] == "application/pdf"
# Verify the PDF data is valid base64 that can be decoded
pdf_data = base64.b64decode(response["source"]["data"])
assert len(pdf_data) > 0
assert pdf_data.startswith(b"%PDF") # PDF signature
@pytest.mark.asyncio
async def test_read_resource_not_found(app):
"""Test trying to read a non-existent resource."""
with pytest.raises(ToolError, match="Error calling tool: Client error '404 Not Found'"):
await resource.read_resource("does-not-exist")
@pytest.mark.asyncio
async def test_read_resource_memory_url(app, synced_files):
"""Test reading a resource using a memory:// URL."""
# Create a text file via notes
await notes.write_note(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling for resources",
)
# Read it with a memory:// URL
memory_url = "memory://test/memory-url-test"
response = await resource.read_resource(memory_url)
assert response["type"] == "text"
assert "Testing memory:// URL handling for resources" in response["text"]
@pytest.mark.asyncio
async def test_image_optimization_functions(app):
"""Test the image optimization helper functions."""
# Create a test image
img = PILImage.new("RGB", (1000, 800), color="white")
# Test calculate_target_params function
# Small image
quality, size = resource.calculate_target_params(100000)
assert quality == 70
assert size == 1000
# Medium image
quality, size = resource.calculate_target_params(800000)
assert quality == 60
assert size == 800
# Large image
quality, size = resource.calculate_target_params(2000000)
assert quality == 50
assert size == 600
# Test resize_image function
# Image that needs resizing
resized = resource.resize_image(img, 500)
assert resized.width <= 500
assert resized.height <= 500
# Image that doesn't need resizing
small_img = PILImage.new("RGB", (300, 200), color="white")
resized = resource.resize_image(small_img, 500)
assert resized.width == 300
assert resized.height == 200
# Test optimize_image function
img_bytes = io.BytesIO()
img.save(img_bytes, format="PNG")
img_bytes.seek(0)
content_length = len(img_bytes.getvalue())
# In a small test image, optimization might make the image larger
# because of JPEG overhead. Let's just test that it returns something
optimized = resource.optimize_image(img, content_length)
assert len(optimized) > 0
@pytest.mark.asyncio
async def test_read_resource_with_transparency(app, synced_files, mocker):
"""Test reading an image with transparency.
Should:
- Convert RGBA images to RGB
- Handle transparency correctly
"""
# Mock the response to simulate an RGBA image
mock_response = mocker.MagicMock()
mock_response.headers = {"content-type": "image/png", "content-length": "10000"}
# Create a test PNG with transparency
img = PILImage.new("RGBA", (500, 400), color=(255, 255, 255, 0))
img_bytes = io.BytesIO()
img.save(img_bytes, format="PNG")
img_bytes.seek(0)
mock_response.content = img_bytes.getvalue()
# Mock call_get to return our transparent image
mocker.patch("basic_memory.mcp.tools.resource.call_get", return_value=mock_response)
# Test reading the resource
response = await resource.read_resource("transparent-image.png")
assert response["type"] == "image"
assert response["source"]["media_type"] == "image/jpeg"
# Verify the image data is valid and was converted to RGB
img_data = base64.b64decode(response["source"]["data"])
img = PILImage.open(io.BytesIO(img_data))
assert img.mode == "RGB" # Should be converted from RGBA to RGB
@pytest.mark.asyncio
async def test_read_resource_large_document(app, mocker):
"""Test handling of documents that exceed the size limit.
Should:
- Detect when document size exceeds limit
- Return appropriate error message
"""
# Mock the response to simulate a large document
mock_response = mocker.MagicMock()
mock_response.headers = {"content-type": "application/octet-stream", "content-length": "500000"}
mock_response.content = b"0" * 500000 # Create a large fake binary document
# Mock call_get to return our large document
mocker.patch("basic_memory.mcp.tools.resource.call_get", return_value=mock_response)
# Test reading the resource
response = await resource.read_resource("large-document.bin")
assert response["type"] == "error"
assert "Document size 500000 bytes exceeds maximum allowed size" in response["error"]
# Let's skip the minimum parameters test since those values are internal to the optimize_image function
# The rest of the code is well covered by the other tests
# @pytest.mark.skip("Minimum parameter test not needed - code already has good coverage")
# @pytest.mark.asyncio
# async def test_optimize_image_limits(app, monkeypatch):
# """Test image optimization when it reaches minimum parameters."""
# pass
+18 -1
View File
@@ -1,6 +1,6 @@
"""Tests for MemoryUrl parsing."""
from basic_memory.schemas.memory import memory_url, memory_url_path
from basic_memory.schemas.memory import memory_url, memory_url_path, normalize_memory_url
def test_basic_permalink():
@@ -44,3 +44,20 @@ def test_str_representation():
"""Test converting back to string."""
url = memory_url.validate_python("memory://specs/search")
assert url == "memory://specs/search"
def test_normalize_memory_url():
"""Test converting back to string."""
url = normalize_memory_url("memory://specs/search")
assert url == "memory://specs/search"
def test_normalize_memory_url_no_prefix():
"""Test converting back to string."""
url = normalize_memory_url("specs/search")
assert url == "memory://specs/search"
def test_normalize_memory_url_empty():
"""Test converting back to string."""
assert normalize_memory_url("") == ""
+14
View File
@@ -23,6 +23,20 @@ def test_entity():
assert entity.entity_type == "knowledge"
def test_entity_non_markdown():
"""Test entity for regular non-markdown file."""
data = {
"title": "Test Entity.txt",
"folder": "test",
"entity_type": "file",
"content_type": "text/plain",
}
entity = Entity.model_validate(data)
assert entity.file_path == "test/Test Entity.txt"
assert entity.permalink == "test/test-entity"
assert entity.entity_type == "file"
def test_entity_in_validation():
"""Test validation errors for EntityIn."""
with pytest.raises(ValidationError):
+5
View File
@@ -46,6 +46,7 @@ def test_search_result():
"""Test search result structure."""
result = SearchResult(
id=1,
title="test",
type=SearchItemType.ENTITY,
score=0.8,
metadata={"entity_type": "component"},
@@ -62,6 +63,7 @@ def test_observation_result():
"""Test observation result fields."""
result = SearchResult(
id=1,
title="test",
permalink="specs/search",
file_path="specs/search.md",
type=SearchItemType.OBSERVATION,
@@ -78,6 +80,7 @@ def test_relation_result():
"""Test relation result fields."""
result = SearchResult(
id=1,
title="test",
permalink="specs/search",
file_path="specs/search.md",
type=SearchItemType.RELATION,
@@ -97,6 +100,7 @@ def test_search_response():
results = [
SearchResult(
id=1,
title="test",
permalink="specs/search",
file_path="specs/search.md",
type=SearchItemType.ENTITY,
@@ -105,6 +109,7 @@ def test_search_response():
),
SearchResult(
id=2,
title="test",
permalink="specs/search",
file_path="specs/search.md",
type=SearchItemType.ENTITY,
+19
View File
@@ -184,6 +184,25 @@ async def test_delete_entity_success(entity_service: EntityService):
await entity_service.get_by_permalink(entity_data.permalink)
@pytest.mark.asyncio
async def test_delete_entity_by_id(entity_service: EntityService):
"""Test successful entity deletion."""
entity_data = EntitySchema(
title="TestEntity",
folder="test",
entity_type="test",
)
created = await entity_service.create_entity(entity_data)
# Act using permalink
result = await entity_service.delete_entity(created.id)
# Assert
assert result is True
with pytest.raises(EntityNotFoundError):
await entity_service.get_by_permalink(entity_data.permalink)
@pytest.mark.asyncio
async def test_get_entity_by_permalink_not_found(entity_service: EntityService):
"""Test handling of non-existent entity retrieval."""
+26 -1
View File
@@ -1,11 +1,14 @@
"""Tests for link resolution service."""
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.models.knowledge import Entity as EntityModel
@pytest_asyncio.fixture
@@ -65,7 +68,19 @@ async def test_entities(entity_service, file_service):
)
)
return [e1, e2, e3, e4]
# non markdown entity
e7 = await entity_service.repository.add(
EntityModel(
title="Image.png",
entity_type="file",
content_type="image/png",
file_path="Image.png",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
)
return [e1, e2, e3, e4, e5, e6, e7]
@pytest_asyncio.fixture
@@ -131,3 +146,13 @@ async def test_resolve_none(link_resolver):
"""Test resolving non-existent entity."""
# Basic new entity
assert await link_resolver.resolve_link("New Feature") is None
@pytest.mark.asyncio
async def test_resolve_file(link_resolver):
"""Test resolving non-existent entity."""
# Basic new entity
resolved = await link_resolver.resolve_link("Image.png")
assert resolved is not None
assert resolved.entity_type == "file"
assert resolved.title == "Image.png"
-245
View File
@@ -1,245 +0,0 @@
"""Test file sync service."""
from pathlib import Path
import pytest
from basic_memory.file_utils import compute_checksum
from basic_memory.models import Entity
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.file_change_scanner import FileState
@pytest.fixture
def temp_dir(tmp_path: Path) -> Path:
"""Create temp directory for test files."""
return tmp_path
async def create_test_file(path: Path, content: str = "test content") -> None:
"""Create a test file with given content."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
@pytest.mark.asyncio
async def test_scan_empty_directory(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test scanning empty directory."""
result = await file_change_scanner.scan_directory(temp_dir)
assert len(result.files) == 0
assert len(result.errors) == 0
@pytest.mark.asyncio
async def test_scan_with_mixed_files(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test scanning directory with markdown and non-markdown files."""
# Create test files
await create_test_file(temp_dir / "doc.md", "markdown")
await create_test_file(temp_dir / "text.txt", "not markdown")
await create_test_file(temp_dir / "notes/deep.md", "nested markdown")
result = await file_change_scanner.scan_directory(temp_dir)
assert len(result.files) == 2
assert "doc.md" in result.files
assert "notes/deep.md" in result.files
assert len(result.errors) == 0
# Verify FileState objects
assert isinstance(result.files["doc.md"], str)
# checksum
assert result.files["doc.md"] is not None
@pytest.mark.asyncio
async def test_scan_with_unreadable_file(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test scanning directory with an unreadable file."""
# Create a file we'll make unreadable
bad_file = temp_dir / "bad.md"
await create_test_file(bad_file)
bad_file.chmod(0o000) # Remove all permissions
result = await file_change_scanner.scan_directory(temp_dir)
assert len(result.files) == 0
assert len(result.errors) == 1
assert "bad.md" in result.errors
@pytest.mark.asyncio
async def test_detect_new_files(
file_change_scanner: FileChangeScanner,
temp_dir: Path,
):
"""Test detection of new files."""
# Create new file
await create_test_file(temp_dir / "new.md")
# Empty DB state
db_records = await file_change_scanner.get_db_file_state([])
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
assert len(changes.new) == 1
assert "new.md" in changes.new
@pytest.mark.asyncio
async def test_detect_modified_file(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test detection of modified files."""
file_path = "test.md"
content = "original"
await create_test_file(temp_dir / file_path, content)
# Create DB state with original checksum
original_checksum = await compute_checksum(content)
db_records = {
file_path: FileState(file_path=file_path, permalink="test", checksum=original_checksum)
}
# Modify file
await create_test_file(temp_dir / file_path, "modified")
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
assert len(changes.modified) == 1
assert file_path in changes.modified
@pytest.mark.asyncio
async def test_detect_deleted_files(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test detection of deleted files."""
file_path = "deleted.md"
# Create DB state with file that doesn't exist
db_records = {
file_path: FileState(file_path=file_path, permalink="deleted", checksum="any-checksum")
}
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
assert len(changes.deleted) == 1
assert file_path in changes.deleted
@pytest.mark.asyncio
async def test_get_db_state_entities(file_change_scanner: FileChangeScanner):
"""Test converting entity records to file states."""
entity = Entity(permalink="concept/test", file_path="concept/test.md", checksum="test-checksum")
db_records = await file_change_scanner.get_db_file_state([entity])
assert len(db_records) == 1
assert "concept/test.md" in db_records
assert db_records["concept/test.md"].checksum == "test-checksum"
@pytest.mark.asyncio
async def test_empty_directory(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test handling empty/nonexistent directory."""
nonexistent = temp_dir / "nonexistent"
changes = await file_change_scanner.find_changes(directory=nonexistent, db_file_state={})
assert changes.total_changes == 0
assert not changes.new
assert not changes.modified
assert not changes.deleted
@pytest.mark.asyncio
async def test_detect_moved_file(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test detection of file moves."""
# Create original file
old_path = "original/test.md"
new_path = "new/location/test.md"
content = "test content"
await create_test_file(temp_dir / old_path, content)
original_checksum = await compute_checksum(content)
# Set up DB state with original location
db_records = {
old_path: FileState(file_path=old_path, permalink="test", checksum=original_checksum)
}
# Move file to new location
old_file = temp_dir / old_path
new_file = temp_dir / new_path
new_file.parent.mkdir(parents=True, exist_ok=True)
old_file.rename(new_file)
# Check changes
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
# Should detect as move
assert len(changes.moves) == 1
assert changes.moves[old_path] == new_path
# Should not be in new or deleted
assert old_path not in changes.new
assert old_path not in changes.deleted
assert new_path not in changes.new
@pytest.mark.asyncio
async def test_move_with_content_change(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test handling a file that is both moved and modified."""
# Create original file
old_path = "original/test.md"
new_path = "new/location/test.md"
content = "original content"
await create_test_file(temp_dir / old_path, content)
original_checksum = await compute_checksum(content)
# Set up DB state with original location
db_records = {
old_path: FileState(file_path=old_path, permalink="test", checksum=original_checksum)
}
# Move file and change content
old_file = temp_dir / old_path
new_file = temp_dir / new_path
new_file.parent.mkdir(parents=True, exist_ok=True)
await create_test_file(new_file, "modified content")
old_file.unlink()
# Check changes
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
# Should be treated as delete + new, not move
assert old_path in changes.deleted
assert new_path in changes.new
assert len(changes.moves) == 0
@pytest.mark.asyncio
async def test_multiple_moves(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test detecting multiple file moves at once."""
# Create original files
files = {"a/test1.md": "content1", "b/test2.md": "content2"}
new_locations = {"a/test1.md": "new/test1.md", "b/test2.md": "new/nested/test2.md"}
db_records = {}
# Create files and DB state
for old_path, content in files.items():
await create_test_file(temp_dir / old_path, content)
checksum = await compute_checksum(content)
db_records[old_path] = FileState(
file_path=old_path, permalink=old_path.replace(".md", ""), checksum=checksum
)
# Move all files
for old_path, new_path in new_locations.items():
old_file = temp_dir / old_path
new_file = temp_dir / new_path
new_file.parent.mkdir(parents=True, exist_ok=True)
old_file.rename(new_file)
# Check changes
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
# Should detect both moves
assert len(changes.moves) == 2
assert changes.moves["a/test1.md"] == "new/test1.md"
assert changes.moves["b/test2.md"] == "new/nested/test2.md"
assert not changes.new
assert not changes.deleted
+188 -7
View File
@@ -48,7 +48,7 @@ type: knowledge
# Verify forward reference
source = await entity_service.get_by_permalink("source")
assert len(source.relations) == 2
assert len(source.relations) == 1
assert source.relations[0].to_id is None
assert source.relations[0].to_name == "target-doc"
@@ -68,13 +68,13 @@ Target content
# Verify reference is now resolved
source = await entity_service.get_by_permalink("source")
target = await entity_service.get_by_permalink("target-doc")
assert len(source.relations) == 2
assert len(source.relations) == 1
assert source.relations[0].to_id == target.id
assert source.relations[0].to_name == target.title
@pytest.mark.asyncio
async def test_sync_knowledge(
async def test_sync(
sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService
):
"""Test basic knowledge sync functionality."""
@@ -130,10 +130,29 @@ A test concept.
# with forward link
entity = await entity_service.get_by_permalink(test_concept.permalink)
relations = entity.relations
assert len(relations) == 1
assert len(relations) == 1, "Expected 1 relation for entity"
assert relations[0].to_name == "concept/other"
@pytest.mark.asyncio
async def test_sync_hidden_file(
sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService
):
"""Test basic knowledge sync functionality."""
# Create test files
project_dir = test_config.home
# hidden file
await create_test_file(project_dir / "concept/.hidden.md", "hidden")
# Run sync
await sync_service.sync(test_config.home)
# Verify results
entities = await entity_service.repository.find_all()
assert len(entities) == 0
@pytest.mark.asyncio
async def test_sync_entity_with_nonexistent_relations(
sync_service: SyncService, test_config: ProjectConfig
@@ -470,8 +489,12 @@ modified: 2024-01-01
# Verify final state
doc = await sync_service.entity_service.repository.get_by_permalink("changing")
assert doc is not None
# File should have a checksum, even if it's from either version
assert doc.checksum is not None
# 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)
doc = await sync_service.entity_service.repository.get_by_permalink("changing")
assert doc.checksum is not None
@pytest.mark.asyncio
@@ -529,7 +552,7 @@ async def test_handle_entity_deletion(
root_entity = test_graph["root"]
# Delete the entity
await sync_service.handle_entity_deletion(root_entity.file_path)
await sync_service.handle_delete(root_entity.file_path)
# Verify entity is gone from db
assert await entity_repository.get_by_permalink(root_entity.permalink) is None
@@ -838,3 +861,161 @@ test content
""".strip()
== file_one_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)
assert report.total == 2
# Check files were detected
assert test_files["pdf"].name in [f for f in report.new]
assert test_files["image"].name in [f for f in report.new]
# Verify entities were created
pdf_entity = await sync_service.entity_repository.get_by_file_path(str(test_files["pdf"].name))
assert pdf_entity is not None, "PDF entity should have been created"
assert pdf_entity.content_type == "application/pdf"
image_entity = await sync_service.entity_repository.get_by_file_path(
str(test_files["image"].name)
)
assert image_entity.content_type == "image/png"
@pytest.mark.asyncio
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)
assert report.total == 2
# Check files were detected
assert test_files["pdf"].name in [f for f in report.new]
assert test_files["image"].name in [f for f in report.new]
test_files["pdf"].write_text("New content")
test_files["image"].write_text("New content")
report = await sync_service.sync(test_config.home)
assert len(report.modified) == 2
pdf_file_content, pdf_checksum = await file_service.read_file(test_files["pdf"].name)
image_file_content, img_checksum = await file_service.read_file(test_files["image"].name)
pdf_entity = await sync_service.entity_repository.get_by_file_path(str(test_files["pdf"].name))
image_entity = await sync_service.entity_repository.get_by_file_path(
str(test_files["image"].name)
)
assert pdf_entity.checksum == pdf_checksum
assert image_entity.checksum == img_checksum
@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)
assert report.total == 2
# Check files were detected
assert test_files["pdf"].name in [f for f in report.new]
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)
assert len(report2.moves) == 1
# Verify entity is updated
pdf_entity = await sync_service.entity_repository.get_by_file_path("moved_pdf.pdf")
assert pdf_entity is not None
assert pdf_entity.permalink is None
@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)
assert report.total == 2
# Check files were detected
assert test_files["pdf"].name in [f for f in report.new]
assert test_files["image"].name in [f for f in report.new]
test_files["pdf"].unlink()
report2 = await sync_service.sync(test_config.home)
assert len(report2.deleted) == 1
# Verify entity is deleted
pdf_entity = await sync_service.entity_repository.get_by_file_path("moved_pdf.pdf")
assert pdf_entity is None
@pytest.mark.asyncio
async def test_sync_non_markdown_files_move_with_delete(
sync_service, test_config, test_files, file_service
):
"""Test syncing non-markdown files handles file deletes and renames during sync"""
# Create initial files
await create_test_file(test_config.home / "doc.pdf", "content1")
await create_test_file(test_config.home / "other/doc-1.pdf", "content2")
# Initial sync
await sync_service.sync(test_config.home)
# 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)
# Verify the changes
moved_entity = await sync_service.entity_repository.get_by_file_path("doc.pdf")
assert moved_entity is not None
assert moved_entity.permalink is None
file_content, _ = await file_service.read_file("doc.pdf")
assert "content2" in file_content
@pytest.mark.asyncio
async def test_sync_relation_to_non_markdown_file(
sync_service: SyncService, test_config: ProjectConfig, file_service: FileService, test_files
):
"""Test that sync resolves permalink conflicts on update."""
project_dir = test_config.home
content = f"""
---
title: a note
type: note
tags: []
---
- relates_to [[{test_files["pdf"].name}]]
"""
note_file = project_dir / "note.md"
await create_test_file(note_file, content)
# Run sync
await sync_service.sync(test_config.home)
# Check permalinks
file_one_content, _ = await file_service.read_file(note_file)
assert (
f"""---
title: a note
type: note
tags: []
permalink: note
---
- relates_to [[{test_files["pdf"].name}]]
""".strip()
== file_one_content
)
+295 -53
View File
@@ -1,39 +1,25 @@
"""Tests for watch service."""
import asyncio
import json
from pathlib import Path
import pytest
from watchfiles import Change
from basic_memory.services.file_service import FileService
from basic_memory.sync.sync_service import SyncService
from basic_memory.sync.watch_service import WatchService, WatchServiceState
from basic_memory.sync.utils import SyncReport
async def create_test_file(path: Path, content: str = "test content") -> None:
"""Create a test file with given content."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
@pytest.fixture
def mock_sync_service(mocker):
"""Create mock sync service."""
service = mocker.Mock(spec=SyncService)
service.sync.return_value = SyncReport(
new={"test.md"},
modified={"modified.md"},
deleted={"deleted.md"},
moves={"old.md": "new.md"},
checksums={"test.md": "abcd1234", "modified.md": "efgh5678", "new.md": "ijkl9012"},
)
return service
@pytest.fixture
def mock_file_service(mocker):
"""Create mock file service."""
return mocker.Mock(spec=FileService)
@pytest.fixture
def watch_service(mock_sync_service, mock_file_service, test_config):
def watch_service(sync_service, file_service, test_config):
"""Create watch service instance."""
return WatchService(mock_sync_service, mock_file_service, test_config)
return WatchService(sync_service, file_service, test_config)
def test_watch_service_init(watch_service, test_config):
@@ -41,14 +27,6 @@ def test_watch_service_init(watch_service, test_config):
assert watch_service.status_path.parent.exists()
def test_filter_changes(watch_service):
"""Test file change filtering."""
assert watch_service.filter_changes(Change.added, "test.md")
assert watch_service.filter_changes(Change.modified, "dir/test.md")
assert not watch_service.filter_changes(Change.added, "test.txt")
assert not watch_service.filter_changes(Change.added, ".hidden.md")
def test_state_add_event():
"""Test adding events to state."""
state = WatchServiceState()
@@ -90,32 +68,296 @@ async def test_write_status(watch_service):
assert data["error_count"] == 0
def test_generate_table(watch_service):
"""Test status table generation."""
# Add some test events
watch_service.state.add_event("test.md", "new", "success", "abcd1234")
watch_service.state.add_event("modified.md", "modified", "success", "efgh5678")
watch_service.state.record_error("test error")
@pytest.mark.asyncio
async def test_handle_file_add(watch_service, test_config):
"""Test handling new file creation."""
project_dir = test_config.home
table = watch_service.generate_table()
assert table is not None
# Setup changes
new_file = project_dir / "new_note.md"
changes = {(Change.added, str(new_file))}
# Create the file
content = """---
type: knowledge
---
# New Note
Test content
"""
await create_test_file(new_file, content)
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify
entity = await watch_service.sync_service.entity_repository.get_by_file_path("new_note.md")
assert entity is not None
assert entity.title == "new_note.md"
# Check event was recorded
events = [e for e in watch_service.state.recent_events if e.action == "new"]
assert len(events) == 1
assert events[0].path == "new_note.md"
assert events[0].status == "success"
@pytest.mark.asyncio
async def test_handle_changes(watch_service, mock_sync_service):
"""Test handling file changes."""
await watch_service.handle_changes(watch_service.config.home)
async def test_handle_file_modify(watch_service, test_config):
"""Test handling file modifications."""
project_dir = test_config.home
# Check sync service was called
mock_sync_service.sync.assert_called_once_with(watch_service.config.home)
# Create initial file
test_file = project_dir / "test_note.md"
initial_content = """---
type: knowledge
---
# Test Note
Initial content
"""
await create_test_file(test_file, initial_content)
# Check events were recorded
# Initial sync
await watch_service.sync_service.sync(project_dir)
# Modify file
modified_content = """---
type: knowledge
---
# Test Note
Modified content
"""
await create_test_file(test_file, modified_content)
# Setup changes
changes = {(Change.modified, str(test_file))}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify
entity = await watch_service.sync_service.entity_repository.get_by_file_path("test_note.md")
assert entity is not None
# Check event was recorded
events = [e for e in watch_service.state.recent_events if e.action == "modified"]
assert len(events) == 1
assert events[0].path == "test_note.md"
assert events[0].status == "success"
@pytest.mark.asyncio
async def test_handle_file_delete(watch_service, test_config):
"""Test handling file deletion."""
project_dir = test_config.home
# Create initial file
test_file = project_dir / "to_delete.md"
content = """---
type: knowledge
---
# Delete Test
Test content
"""
await create_test_file(test_file, content)
# Initial sync
await watch_service.sync_service.sync(project_dir)
# Delete file
test_file.unlink()
# Setup changes
changes = {(Change.deleted, str(test_file))}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify
entity = await watch_service.sync_service.entity_repository.get_by_file_path("to_delete.md")
assert entity is None
# Check event was recorded
events = [e for e in watch_service.state.recent_events if e.action == "deleted"]
assert len(events) == 1
assert events[0].path == "to_delete.md"
assert events[0].status == "success"
@pytest.mark.asyncio
async def test_handle_file_move(watch_service, test_config):
"""Test handling file moves."""
project_dir = test_config.home
# Create initial file
old_path = project_dir / "old" / "test_move.md"
content = """---
type: knowledge
---
# Move Test
Test content
"""
await create_test_file(old_path, content)
# Initial sync
await watch_service.sync_service.sync(project_dir)
initial_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"old/test_move.md"
)
# Move file
new_path = project_dir / "new" / "moved_file.md"
new_path.parent.mkdir(parents=True)
old_path.rename(new_path)
# Setup changes
changes = {(Change.deleted, str(old_path)), (Change.added, str(new_path))}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify
moved_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"new/moved_file.md"
)
assert moved_entity is not None
assert moved_entity.id == initial_entity.id # Same entity, new path
# Original path should no longer exist
old_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"old/test_move.md"
)
assert old_entity is None
# Check event was recorded
events = [e for e in watch_service.state.recent_events if e.action == "moved"]
assert len(events) == 1
assert events[0].path == "old/test_move.md -> new/moved_file.md"
assert events[0].status == "success"
@pytest.mark.asyncio
async def test_handle_concurrent_changes(watch_service, test_config):
"""Test handling multiple file changes happening close together."""
project_dir = test_config.home
# Create multiple files with small delays to simulate concurrent changes
async def create_files():
# Create first file
file1 = project_dir / "note1.md"
await create_test_file(file1, "First note")
await asyncio.sleep(0.1)
# Create second file
file2 = project_dir / "note2.md"
await create_test_file(file2, "Second note")
await asyncio.sleep(0.1)
# Modify first file
await create_test_file(file1, "Modified first note")
return file1, file2
# Create files and collect changes
file1, file2 = await create_files()
# Setup combined changes
changes = {
(Change.added, str(file1)),
(Change.modified, str(file1)),
(Change.added, str(file2)),
}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify both files were processed
entity1 = await watch_service.sync_service.entity_repository.get_by_file_path("note1.md")
entity2 = await watch_service.sync_service.entity_repository.get_by_file_path("note2.md")
assert entity1 is not None
assert entity2 is not None
# Check events were recorded in correct order
events = watch_service.state.recent_events
assert len(events) == 4 # new, modified, moved, deleted
# Check specific events
actions = [e.action for e in events]
assert "new" in actions
assert "modified" in actions
assert "moved" in actions
assert "deleted" in actions
assert "modified" not in actions # only process file once
@pytest.mark.asyncio
async def test_handle_rapid_move(watch_service, test_config):
"""Test handling rapid move operations."""
project_dir = test_config.home
# Create initial file
original_path = project_dir / "original.md"
content = """---
type: knowledge
---
# Move Test
Test content for rapid moves
"""
await create_test_file(original_path, content)
await watch_service.sync_service.sync(project_dir)
# Perform rapid moves
temp_path = project_dir / "temp.md"
final_path = project_dir / "final.md"
original_path.rename(temp_path)
await asyncio.sleep(0.1)
temp_path.rename(final_path)
# Setup changes that might come in various orders
changes = {
(Change.deleted, str(original_path)),
(Change.added, str(temp_path)),
(Change.deleted, str(temp_path)),
(Change.added, str(final_path)),
}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify final state
final_entity = await watch_service.sync_service.entity_repository.get_by_file_path("final.md")
assert final_entity is not None
# Intermediate paths should not exist
original_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"original.md"
)
temp_entity = await watch_service.sync_service.entity_repository.get_by_file_path("temp.md")
assert original_entity is None
assert temp_entity is None
@pytest.mark.asyncio
async def test_handle_delete_then_add(watch_service, test_config):
"""Test handling rapid move operations."""
project_dir = test_config.home
# Create initial file
original_path = project_dir / "original.md"
content = """---
type: knowledge
---
# Move Test
Test content for rapid moves
"""
await create_test_file(original_path, content)
# Setup changes that might come in various orders
changes = {
(Change.deleted, str(original_path)),
(Change.added, str(original_path)),
}
# Handle changes
await watch_service.handle_changes(project_dir, changes)
# Verify final state
original_entity = await watch_service.sync_service.entity_repository.get_by_file_path(
"original.md"
)
assert original_entity is None # delete event is handled
@@ -0,0 +1,75 @@
"""Test edge cases in the WatchService."""
from pathlib import Path
from unittest.mock import patch
import pytest
from watchfiles import Change
def test_filter_changes_valid_path(watch_service, test_config):
"""Test the filter_changes method with valid non-hidden paths."""
# Regular file path
assert (
watch_service.filter_changes(Change.added, str(test_config.home / "valid_file.txt")) is True
)
# Nested path
assert (
watch_service.filter_changes(
Change.added, str(test_config.home / "nested" / "valid_file.txt")
)
is True
)
def test_filter_changes_hidden_path(watch_service, test_config):
"""Test the filter_changes method with hidden files/directories."""
# Hidden file (starts with dot)
assert (
watch_service.filter_changes(Change.added, str(test_config.home / ".hidden_file.txt"))
is False
)
# File in hidden directory
assert (
watch_service.filter_changes(
Change.added, str(test_config.home / ".hidden_dir" / "file.txt")
)
is False
)
# Deeply nested hidden directory
assert (
watch_service.filter_changes(
Change.added, str(test_config.home / "valid" / ".hidden" / "file.txt")
)
is False
)
def test_filter_changes_invalid_path(watch_service, test_config):
"""Test the filter_changes method with invalid paths."""
# Path outside of config.home
outside_path = Path("/tmp/outside_path.txt")
assert watch_service.filter_changes(Change.added, str(outside_path)) is False
@pytest.mark.asyncio
async def test_handle_changes_empty_set(watch_service, test_config):
"""Test handle_changes with an empty set (no processed files)."""
# Mock write_status to avoid file operations
with patch.object(watch_service, "write_status", return_value=None):
# Capture console output to verify
with patch.object(watch_service.console, "print") as mock_print:
# Call handle_changes with empty set
await watch_service.handle_changes(test_config.home, set())
# Verify divider wasn't printed (processed is empty)
mock_print.assert_not_called()
# Verify last_scan was updated
assert watch_service.state.last_scan is not None
# Verify synced_files wasn't changed
assert watch_service.state.synced_files == 0
Generated
+115 -75
View File
@@ -52,7 +52,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13' and python_full_version >= '3.12.1'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
wheels = [
@@ -79,7 +79,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.6.0"
version = "0.7.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -92,6 +92,7 @@ dependencies = [
{ name = "loguru" },
{ name = "markdown-it-py" },
{ name = "mcp" },
{ name = "pillow" },
{ name = "pydantic", extra = ["email", "timezone"] },
{ name = "pydantic-settings" },
{ name = "pyright" },
@@ -130,6 +131,7 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "markdown-it-py", specifier = ">=3.0.0" },
{ name = "mcp", specifier = ">=1.2.0" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" },
{ name = "pydantic-settings", specifier = ">=2.6.1" },
{ name = "pyright", specifier = ">=1.1.390" },
@@ -397,16 +399,16 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.8"
version = "0.115.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/b2/5a5dc4affdb6661dea100324e19a7721d5dc524b464fe8e366c093fd7d87/fastapi-0.115.8.tar.gz", hash = "sha256:0ce9111231720190473e222cdf0f07f7206ad7e53ea02beb1d2dc36e2f0741e9", size = 295403 }
sdist = { url = "https://files.pythonhosted.org/packages/ab/dd/d854f85e70f7341b29e3fda754f2833aec197bd355f805238758e3bcd8ed/fastapi-0.115.9.tar.gz", hash = "sha256:9d7da3b196c5eed049bc769f9475cd55509a112fbe031c0ef2f53768ae68d13f", size = 293774 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/7d/2d6ce181d7a5f51dedb8c06206cbf0ec026a99bf145edd309f9e17c3282f/fastapi-0.115.8-py3-none-any.whl", hash = "sha256:753a96dd7e036b34eeef8babdfcfe3f28ff79648f86551eb36bfc1b0bf4a8cbf", size = 94814 },
{ url = "https://files.pythonhosted.org/packages/32/b6/7517af5234378518f27ad35a7b24af9591bc500b8c1780929c1295999eb6/fastapi-0.115.9-py3-none-any.whl", hash = "sha256:4a439d7923e4de796bcc88b64e9754340fcd1574673cbd865ba8a99fe0d28c56", size = 94919 },
]
[package.optional-dependencies]
@@ -479,14 +481,14 @@ wheels = [
[[package]]
name = "googleapis-common-protos"
version = "1.67.0"
version = "1.68.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/e1/fbffb85a624f1404133b5bb624834e77e0f549e2b8548146fe18c56e1411/googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86", size = 57344 }
sdist = { url = "https://files.pythonhosted.org/packages/54/d2/c08f0d9f94b45faca68e355771329cba2411c777c8713924dd1baee0e09c/googleapis_common_protos-1.68.0.tar.gz", hash = "sha256:95d38161f4f9af0d9423eed8fb7b64ffd2568c3464eb542ff02c5bfa1953ab3c", size = 57367 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/30/2bd0eb03a7dee7727cd2ec643d1e992979e62d5e7443507381cce0455132/googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741", size = 164985 },
{ url = "https://files.pythonhosted.org/packages/3f/85/c99a157ee99d67cc6c9ad123abb8b1bfb476fab32d2f3511c59314548e4f/googleapis_common_protos-1.68.0-py2.py3-none-any.whl", hash = "sha256:aaf179b2f81df26dfadac95def3b16a95064c76a5f45f07e4c68a21bb371c4ac", size = 164985 },
]
[[package]]
@@ -660,7 +662,7 @@ wheels = [
[[package]]
name = "logfire"
version = "3.6.0"
version = "3.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "executing" },
@@ -671,9 +673,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/03/3ed1bab39509c0d9bd1048387bceee75e6229c520eec9f7200ff85714b91/logfire-3.6.0.tar.gz", hash = "sha256:065a12dc727c92450bb05c0bf17b6ca36d1ee1ba99b6ca53d0f88d877ac863d9", size = 268596 }
sdist = { url = "https://files.pythonhosted.org/packages/9c/34/3ee5a691d22dd0935b42673e21577f01c46554b4526c10912a25ebb477bb/logfire-3.6.4.tar.gz", hash = "sha256:6933aa6ae4625a100238a37ed33f4ad03db5ea063520c28ca023fe4853429526", size = 269621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/7f/f1baa1f8ba5e5a8a1a892b42589a0e51878db0385893b043cf75734c3b7e/logfire-3.6.0-py3-none-any.whl", hash = "sha256:9d215ede261cb579d4bad77ff3419ee17145a2a87a9864e1fda21f3143fa2307", size = 180916 },
{ url = "https://files.pythonhosted.org/packages/c4/ee/f56ba29655b0e75ce4dc7cbc157ed32ccb850e771c6dc8f78cd3221b8b17/logfire-3.6.4-py3-none-any.whl", hash = "sha256:907cd6b7ffee4e9e9c30f620a3f5cb4fa44b81ef6d3e23c33f0fbe02fc3584fc", size = 181272 },
]
[package.optional-dependencies]
@@ -776,7 +778,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.2.1"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -788,9 +790,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/30/51e4555826126e3954fa2ab1e934bf74163c5fe05e98f38ca4d0f8abbf63/mcp-1.2.1.tar.gz", hash = "sha256:c9d43dbfe943aa1530e2be8f54b73af3ebfb071243827b4483d421684806cb45", size = 103968 }
sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/81e5f2490290351fc97bf46c24ff935128cb7d34d68e3987b522f26f7ada/mcp-1.3.0.tar.gz", hash = "sha256:f409ae4482ce9d53e7ac03f3f7808bcab735bdfc0fba937453782efb43882d45", size = 150235 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/0d/6770742a84c8aa1d36c0d628896a380584c5759612e66af7446af07d8775/mcp-1.2.1-py3-none-any.whl", hash = "sha256:579bf9c9157850ebb1344f3ca6f7a3021b0123c44c9f089ef577a7062522f0fd", size = 66453 },
{ url = "https://files.pythonhosted.org/packages/d0/d2/a9e87b506b2094f5aa9becc1af5178842701b27217fa43877353da2577e3/mcp-1.3.0-py3-none-any.whl", hash = "sha256:2829d67ce339a249f803f22eba5e90385eafcac45c94b00cab6cef7e8f217211", size = 70672 },
]
[[package]]
@@ -1033,6 +1035,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/73/c3105c973dd2afcdc5d946ee211d5c4ecdf9d27bb54ae835b144e706e86d/patchelf-0.17.2.1-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:d1a9bc0d4fd80c038523ebdc451a1cce75237cfcc52dbd1aca224578001d5927", size = 425709 },
]
[[package]]
name = "pillow"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/20/9ce6ed62c91c073fcaa23d216e68289e19d95fb8188b9fb7a63d36771db8/pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a", size = 3226818 },
{ url = "https://files.pythonhosted.org/packages/b9/d8/f6004d98579a2596c098d1e30d10b248798cceff82d2b77aa914875bfea1/pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b", size = 3101662 },
{ url = "https://files.pythonhosted.org/packages/08/d9/892e705f90051c7a2574d9f24579c9e100c828700d78a63239676f960b74/pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3", size = 4329317 },
{ url = "https://files.pythonhosted.org/packages/8c/aa/7f29711f26680eab0bcd3ecdd6d23ed6bce180d82e3f6380fb7ae35fcf3b/pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a", size = 4412999 },
{ url = "https://files.pythonhosted.org/packages/c8/c4/8f0fe3b9e0f7196f6d0bbb151f9fba323d72a41da068610c4c960b16632a/pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1", size = 4368819 },
{ url = "https://files.pythonhosted.org/packages/38/0d/84200ed6a871ce386ddc82904bfadc0c6b28b0c0ec78176871a4679e40b3/pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f", size = 4496081 },
{ url = "https://files.pythonhosted.org/packages/84/9c/9bcd66f714d7e25b64118e3952d52841a4babc6d97b6d28e2261c52045d4/pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91", size = 4296513 },
{ url = "https://files.pythonhosted.org/packages/db/61/ada2a226e22da011b45f7104c95ebda1b63dcbb0c378ad0f7c2a710f8fd2/pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c", size = 4431298 },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fc6e86750523f367923522014b821c11ebc5ad402e659d8c9d09b3c9d70c/pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6", size = 2291630 },
{ url = "https://files.pythonhosted.org/packages/08/5c/2104299949b9d504baf3f4d35f73dbd14ef31bbd1ddc2c1b66a5b7dfda44/pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf", size = 2626369 },
{ url = "https://files.pythonhosted.org/packages/37/f3/9b18362206b244167c958984b57c7f70a0289bfb59a530dd8af5f699b910/pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5", size = 2375240 },
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640 },
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437 },
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605 },
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173 },
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145 },
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340 },
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906 },
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759 },
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657 },
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304 },
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117 },
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060 },
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192 },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805 },
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623 },
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191 },
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494 },
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595 },
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
@@ -1084,7 +1124,7 @@ email = [
{ name = "email-validator" },
]
timezone = [
{ name = "tzdata", marker = "platform_system == 'Windows' and python_full_version >= '3.12.1'" },
{ name = "tzdata", marker = "platform_system == 'Windows'" },
]
[[package]]
@@ -1128,15 +1168,15 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.7.1"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 }
sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 },
{ url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 },
]
[[package]]
@@ -1198,15 +1238,15 @@ wheels = [
[[package]]
name = "pyright"
version = "1.1.394"
version = "1.1.395"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/e4/79f4d8a342eed6790fdebdb500e95062f319ee3d7d75ae27304ff995ae8c/pyright-1.1.394.tar.gz", hash = "sha256:56f2a3ab88c5214a451eb71d8f2792b7700434f841ea219119ade7f42ca93608", size = 3809348 }
sdist = { url = "https://files.pythonhosted.org/packages/fb/47/a2e1dfd70f9f0db34f70d5b108c82be57bf24185af69c95acff57f9239fa/pyright-1.1.395.tar.gz", hash = "sha256:53703169068c160bfb41e1b44ba3e2512492869c26cfad927e1268cb3fbb1b1c", size = 3813566 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/4c/50c74e3d589517a9712a61a26143b587dba6285434a17aebf2ce6b82d2c3/pyright-1.1.394-py3-none-any.whl", hash = "sha256:5f74cce0a795a295fb768759bbeeec62561215dea657edcaab48a932b031ddbb", size = 5679540 },
{ url = "https://files.pythonhosted.org/packages/5f/a1/531897f8caa6c6cc99862cd1c908ddd8a366a51d968e83ab4523ded98b30/pyright-1.1.395-py3-none-any.whl", hash = "sha256:f9bc726870e740c6c77c94657734d90563a3e9765bb523b39f5860198ed75eef", size = 5688787 },
]
[[package]]
@@ -1429,36 +1469,36 @@ wheels = [
[[package]]
name = "ruff"
version = "0.9.6"
version = "0.9.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 }
sdist = { url = "https://files.pythonhosted.org/packages/e9/59/ac745a2492986a4c900c73a7a3a10eb4d7a3853e43443519bceecae5eefc/ruff-0.9.8.tar.gz", hash = "sha256:12d455f2be6fe98accbea2487bbb8eaec716c760bf60b45e7e13f76f913f56e9", size = 3715230 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 },
{ url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 },
{ url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 },
{ url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 },
{ url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 },
{ url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 },
{ url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 },
{ url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 },
{ url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 },
{ url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 },
{ url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 },
{ url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 },
{ url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 },
{ url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 },
{ url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 },
{ url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 },
{ url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 },
{ url = "https://files.pythonhosted.org/packages/5c/1c/9de3a463279e9a203104fe80881d7dcfd8377eb52b3d5608770ea6ff3dc6/ruff-0.9.8-py3-none-linux_armv6l.whl", hash = "sha256:d236f0ce0190bbc6fa9b4c4b85e916fb4c50fd087e6558af1bf5a45eb20e374d", size = 10036520 },
{ url = "https://files.pythonhosted.org/packages/35/10/a4eda083ad0b60a4c16bc9a68c6eda59de69a3a58913a0b62541f5c551cd/ruff-0.9.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:59fac6922b336d0c38df199761ade561563e1b7636e3a2b767b9ee5a68aa9cbf", size = 10827099 },
{ url = "https://files.pythonhosted.org/packages/57/34/cf7e18f2315926ee2c98f931717e1302f8c3face189f5b99352eb48c5373/ruff-0.9.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a82082ec72bde2166ec138055307396c4d4e543fd97266dc2bfa24284cb30af6", size = 10161605 },
{ url = "https://files.pythonhosted.org/packages/f3/08/5e7e8fc08d193e3520b9227249a00bc9b8da9e0a20bf97bef03a9a9f0d38/ruff-0.9.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e06635d12321605d1d11226c7d3c6b1245a0df498099868d14b4e353b3f0ac22", size = 10338840 },
{ url = "https://files.pythonhosted.org/packages/54/c0/df2187618b87334867ea7942f6d2d79ea3e5cb3ed709cfa5c8df115d3715/ruff-0.9.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:65961815bb35d427e957940d13b2a1d0a67d8b245d3a7e0b5a4a2058536d3532", size = 9891009 },
{ url = "https://files.pythonhosted.org/packages/fb/39/8fc50b87203e71e6f3281111813ab0f3d6095cb1129efc2cf4c33e977657/ruff-0.9.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c18356beaef174797ad83f11debc5569e96afa73a549b2d073912565cfc4cfd1", size = 11413420 },
{ url = "https://files.pythonhosted.org/packages/6a/7b/53cd91b99a1cef31126859fb98fdc347c47e0047a9ec51391ea28f08284d/ruff-0.9.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a1dfc443bee0288ea926a4d9ecfd858bf94ddf0a03a256c63e81b2b6dccdfc7d", size = 12138017 },
{ url = "https://files.pythonhosted.org/packages/1a/d4/949a328934202a2d2641dcd759761d8ed806e672cbbad0a88e20a46c43ba/ruff-0.9.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc86d5a85cd5ab1d5aff1650f038aa34681d0692cc2467aa9ddef37bd56ea3f9", size = 11592548 },
{ url = "https://files.pythonhosted.org/packages/c6/8e/8520a4d97eefedb8472811fd5144fcb1fcbb29f83bb9bb4356a468e7eeac/ruff-0.9.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66662aa19535d58fe6d04e5b59a39e495b102f2f5a2a1b9698e240eb78f429ef", size = 13787277 },
{ url = "https://files.pythonhosted.org/packages/24/68/f1629e00dbc5c9adcd31f12f9438b68c50ab0eefca8b07e11b6c94f11b09/ruff-0.9.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:733647b2fe9367e1aa049c0eba296363746f3bc0dbfd454b0bc4b7b46cdf0146", size = 11275421 },
{ url = "https://files.pythonhosted.org/packages/28/65/c133462f179b925e49910532c7d7b5a244df5995c155cd2ab9452545926f/ruff-0.9.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:100031be9777f67af7f61b4d4eea2a0531ed6788940aca4360f6b9aae317c53b", size = 10220273 },
{ url = "https://files.pythonhosted.org/packages/d8/1e/9339aef1896470380838385dbdc91f62998c37d406009f05ff3b810265f3/ruff-0.9.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f090758d58b4667d9022eee1085a854db93d800279e5a177ebda5adc1faf639", size = 9860266 },
{ url = "https://files.pythonhosted.org/packages/ca/33/2a2934860df6bd3665776ec686fc33910e7a1b793bdd2f000aea3e8f0b65/ruff-0.9.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f774998b9c9a062510533aba9b53085de6be6d41e13a7a0bd086af8a40e838c3", size = 10831947 },
{ url = "https://files.pythonhosted.org/packages/74/66/0a7677b1cda4b2367a654f9af57f1dbe58f38c6704da88aee9bbf3941197/ruff-0.9.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6ef7cc80626264ab8ab4d68b359ba867b8a52b0830a9643cd31289146dd40892", size = 11306767 },
{ url = "https://files.pythonhosted.org/packages/c4/90/6c98f94e036c8acdf19bd8f3f84d246e43cbcc950e24dc7ff85d2f2735ba/ruff-0.9.8-py3-none-win32.whl", hash = "sha256:54b57b623a683e696a1ede99db95500763c1badafe105b6ad8d8e9d96e385ae2", size = 10234107 },
{ url = "https://files.pythonhosted.org/packages/f5/e7/35877491b4b64daa35cbd7dc06aa5969e7bb1cd6f69e5594e4376dfbc16d/ruff-0.9.8-py3-none-win_amd64.whl", hash = "sha256:b0878103b2fb8af55ad701308a69ce713108ad346c3a3a143ebcd1e13829c9a7", size = 11357825 },
{ url = "https://files.pythonhosted.org/packages/6e/98/de77a972b2e9ded804dea5d4e6fbfa093d99e81092602567787ea87979af/ruff-0.9.8-py3-none-win_arm64.whl", hash = "sha256:e459a4fc4150fcc60da26c59a6a4b70878c60a99df865a71cf6f958dc68c419a", size = 10435420 },
]
[[package]]
name = "setuptools"
version = "75.8.0"
version = "75.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ec/089608b791d210aec4e7f97488e67ab0d33add3efccb83a056cbafe3a2a6/setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", size = 1343222 }
sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/8a/b9dc7678803429e4a3bc9ba462fa3dd9066824d3c607490235c6a796be5a/setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3", size = 1228782 },
{ url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 },
]
[[package]]
@@ -1493,7 +1533,7 @@ name = "sqlalchemy"
version = "2.0.38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'WIN32' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'aarch64' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'amd64' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'ppc64le' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'win32' and python_full_version >= '3.12.1') or (python_full_version < '3.14' and platform_machine == 'x86_64' and python_full_version >= '3.12.1')" },
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/08/9a90962ea72acd532bda71249a626344d855c4032603924b1b547694b837/sqlalchemy-2.0.38.tar.gz", hash = "sha256:e5a4d82bdb4bf1ac1285a68eab02d253ab73355d9f0fe725a97e1e0fa689decb", size = 9634782 }
@@ -1544,7 +1584,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.15.1"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -1552,9 +1592,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
]
[[package]]
@@ -1577,14 +1617,14 @@ wheels = [
[[package]]
name = "tzlocal"
version = "5.2"
version = "5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 }
sdist = { url = "https://files.pythonhosted.org/packages/33/cc/11360404b20a6340b9b4ed39a3338c4af47bc63f87f6cea94dbcbde07029/tzlocal-5.3.tar.gz", hash = "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2", size = 30480 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 },
{ url = "https://files.pythonhosted.org/packages/e9/9f/1c0b69d3abf4c65acac051ad696b8aea55afbb746dea8017baab53febb5e/tzlocal-5.3-py3-none-any.whl", hash = "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c", size = 17920 },
]
[[package]]
@@ -1687,33 +1727,33 @@ wheels = [
[[package]]
name = "websockets"
version = "14.2"
version = "15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/54/8359678c726243d19fae38ca14a334e740782336c9f19700858c4eb64a1e/websockets-14.2.tar.gz", hash = "sha256:5059ed9c54945efb321f097084b4c7e52c246f2c869815876a69d1efc4ad6eb5", size = 164394 }
sdist = { url = "https://files.pythonhosted.org/packages/2e/7a/8bc4d15af7ff30f7ba34f9a172063bfcee9f5001d7cef04bee800a658f33/websockets-15.0.tar.gz", hash = "sha256:ca36151289a15b39d8d683fd8b7abbe26fc50be311066c5f8dcf3cb8cee107ab", size = 175574 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/81/04f7a397653dc8bec94ddc071f34833e8b99b13ef1a3804c149d59f92c18/websockets-14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f20522e624d7ffbdbe259c6b6a65d73c895045f76a93719aa10cd93b3de100c", size = 163096 },
{ url = "https://files.pythonhosted.org/packages/ec/c5/de30e88557e4d70988ed4d2eabd73fd3e1e52456b9f3a4e9564d86353b6d/websockets-14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:647b573f7d3ada919fd60e64d533409a79dcf1ea21daeb4542d1d996519ca967", size = 160758 },
{ url = "https://files.pythonhosted.org/packages/e5/8c/d130d668781f2c77d106c007b6c6c1d9db68239107c41ba109f09e6c218a/websockets-14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af99a38e49f66be5a64b1e890208ad026cda49355661549c507152113049990", size = 160995 },
{ url = "https://files.pythonhosted.org/packages/a6/bc/f6678a0ff17246df4f06765e22fc9d98d1b11a258cc50c5968b33d6742a1/websockets-14.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:091ab63dfc8cea748cc22c1db2814eadb77ccbf82829bac6b2fbe3401d548eda", size = 170815 },
{ url = "https://files.pythonhosted.org/packages/d8/b2/8070cb970c2e4122a6ef38bc5b203415fd46460e025652e1ee3f2f43a9a3/websockets-14.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b374e8953ad477d17e4851cdc66d83fdc2db88d9e73abf755c94510ebddceb95", size = 169759 },
{ url = "https://files.pythonhosted.org/packages/81/da/72f7caabd94652e6eb7e92ed2d3da818626e70b4f2b15a854ef60bf501ec/websockets-14.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a39d7eceeea35db85b85e1169011bb4321c32e673920ae9c1b6e0978590012a3", size = 170178 },
{ url = "https://files.pythonhosted.org/packages/31/e0/812725b6deca8afd3a08a2e81b3c4c120c17f68c9b84522a520b816cda58/websockets-14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a6f3efd47ffd0d12080594f434faf1cd2549b31e54870b8470b28cc1d3817d9", size = 170453 },
{ url = "https://files.pythonhosted.org/packages/66/d3/8275dbc231e5ba9bb0c4f93144394b4194402a7a0c8ffaca5307a58ab5e3/websockets-14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:065ce275e7c4ffb42cb738dd6b20726ac26ac9ad0a2a48e33ca632351a737267", size = 169830 },
{ url = "https://files.pythonhosted.org/packages/a3/ae/e7d1a56755ae15ad5a94e80dd490ad09e345365199600b2629b18ee37bc7/websockets-14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9d0e53530ba7b8b5e389c02282f9d2aa47581514bd6049d3a7cffe1385cf5fe", size = 169824 },
{ url = "https://files.pythonhosted.org/packages/b6/32/88ccdd63cb261e77b882e706108d072e4f1c839ed723bf91a3e1f216bf60/websockets-14.2-cp312-cp312-win32.whl", hash = "sha256:20e6dd0984d7ca3037afcb4494e48c74ffb51e8013cac71cf607fffe11df7205", size = 163981 },
{ url = "https://files.pythonhosted.org/packages/b3/7d/32cdb77990b3bdc34a306e0a0f73a1275221e9a66d869f6ff833c95b56ef/websockets-14.2-cp312-cp312-win_amd64.whl", hash = "sha256:44bba1a956c2c9d268bdcdf234d5e5ff4c9b6dc3e300545cbe99af59dda9dcce", size = 164421 },
{ url = "https://files.pythonhosted.org/packages/82/94/4f9b55099a4603ac53c2912e1f043d6c49d23e94dd82a9ce1eb554a90215/websockets-14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f1372e511c7409a542291bce92d6c83320e02c9cf392223272287ce55bc224e", size = 163102 },
{ url = "https://files.pythonhosted.org/packages/8e/b7/7484905215627909d9a79ae07070057afe477433fdacb59bf608ce86365a/websockets-14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4da98b72009836179bb596a92297b1a61bb5a830c0e483a7d0766d45070a08ad", size = 160766 },
{ url = "https://files.pythonhosted.org/packages/a3/a4/edb62efc84adb61883c7d2c6ad65181cb087c64252138e12d655989eec05/websockets-14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8a86a269759026d2bde227652b87be79f8a734e582debf64c9d302faa1e9f03", size = 160998 },
{ url = "https://files.pythonhosted.org/packages/f5/79/036d320dc894b96af14eac2529967a6fc8b74f03b83c487e7a0e9043d842/websockets-14.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86cf1aaeca909bf6815ea714d5c5736c8d6dd3a13770e885aafe062ecbd04f1f", size = 170780 },
{ url = "https://files.pythonhosted.org/packages/63/75/5737d21ee4dd7e4b9d487ee044af24a935e36a9ff1e1419d684feedcba71/websockets-14.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b0f6c3ba3b1240f602ebb3971d45b02cc12bd1845466dd783496b3b05783a5", size = 169717 },
{ url = "https://files.pythonhosted.org/packages/2c/3c/bf9b2c396ed86a0b4a92ff4cdaee09753d3ee389be738e92b9bbd0330b64/websockets-14.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c3e101c246aa85bc8534e495952e2ca208bd87994650b90a23d745902db9a", size = 170155 },
{ url = "https://files.pythonhosted.org/packages/75/2d/83a5aca7247a655b1da5eb0ee73413abd5c3a57fc8b92915805e6033359d/websockets-14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eabdb28b972f3729348e632ab08f2a7b616c7e53d5414c12108c29972e655b20", size = 170495 },
{ url = "https://files.pythonhosted.org/packages/79/dd/699238a92761e2f943885e091486378813ac8f43e3c84990bc394c2be93e/websockets-14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2066dc4cbcc19f32c12a5a0e8cc1b7ac734e5b64ac0a325ff8353451c4b15ef2", size = 169880 },
{ url = "https://files.pythonhosted.org/packages/c8/c9/67a8f08923cf55ce61aadda72089e3ed4353a95a3a4bc8bf42082810e580/websockets-14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab95d357cd471df61873dadf66dd05dd4709cae001dd6342edafc8dc6382f307", size = 169856 },
{ url = "https://files.pythonhosted.org/packages/17/b1/1ffdb2680c64e9c3921d99db460546194c40d4acbef999a18c37aa4d58a3/websockets-14.2-cp313-cp313-win32.whl", hash = "sha256:a9e72fb63e5f3feacdcf5b4ff53199ec8c18d66e325c34ee4c551ca748623bbc", size = 163974 },
{ url = "https://files.pythonhosted.org/packages/14/13/8b7fc4cb551b9cfd9890f0fd66e53c18a06240319915533b033a56a3d520/websockets-14.2-cp313-cp313-win_amd64.whl", hash = "sha256:b439ea828c4ba99bb3176dc8d9b933392a2413c0f6b149fdcba48393f573377f", size = 164420 },
{ url = "https://files.pythonhosted.org/packages/7b/c8/d529f8a32ce40d98309f4470780631e971a5a842b60aec864833b3615786/websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b", size = 157416 },
{ url = "https://files.pythonhosted.org/packages/22/1e/92c4547d7b2a93f848aedaf37e9054111bc00dc11bff4385ca3f80dbb412/websockets-15.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cccc18077acd34c8072578394ec79563664b1c205f7a86a62e94fafc7b59001f", size = 174709 },
{ url = "https://files.pythonhosted.org/packages/9f/37/eae4830a28061ba552516d84478686b637cd9e57d6a90b45ad69e89cb0af/websockets-15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4c22992e24f12de340ca5f824121a5b3e1a37ad4360b4e1aaf15e9d1c42582d", size = 172372 },
{ url = "https://files.pythonhosted.org/packages/46/2f/b409f8b8aa9328d5a47f7a301a43319d540d70cf036d1e6443675978a988/websockets-15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1206432cc6c644f6fc03374b264c5ff805d980311563202ed7fef91a38906276", size = 172607 },
{ url = "https://files.pythonhosted.org/packages/d6/81/d7e2e4542d4b4df849b0110df1b1f94f2647b71ab4b65d672090931ad2bb/websockets-15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3cc75ef3e17490042c47e0523aee1bcc4eacd2482796107fd59dd1100a44bc", size = 182422 },
{ url = "https://files.pythonhosted.org/packages/b6/91/3b303160938d123eea97f58be363f7dbec76e8c59d587e07b5bc257dd584/websockets-15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b89504227a5311610e4be16071465885a0a3d6b0e82e305ef46d9b064ce5fb72", size = 181362 },
{ url = "https://files.pythonhosted.org/packages/f2/8b/df6807f1ca339c567aba9a7ab03bfdb9a833f625e8d2b4fc7529e4c701de/websockets-15.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56e3efe356416bc67a8e093607315951d76910f03d2b3ad49c4ade9207bf710d", size = 181787 },
{ url = "https://files.pythonhosted.org/packages/21/37/e6d3d5ebb0ebcaf98ae84904205c9dcaf3e0fe93e65000b9f08631ed7309/websockets-15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f2205cdb444a42a7919690238fb5979a05439b9dbb73dd47c863d39640d85ab", size = 182058 },
{ url = "https://files.pythonhosted.org/packages/c9/df/6aca296f2be4c638ad20908bb3d7c94ce7afc8d9b4b2b0780d1fc59b359c/websockets-15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aea01f40995fa0945c020228ab919b8dfc93fc8a9f2d3d705ab5b793f32d9e99", size = 181434 },
{ url = "https://files.pythonhosted.org/packages/88/f1/75717a982bab39bbe63c83f9df0e7753e5c98bab907eb4fb5d97fe5c8c11/websockets-15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9f8e33747b1332db11cf7fcf4a9512bef9748cb5eb4d3f7fbc8c30d75dc6ffc", size = 181431 },
{ url = "https://files.pythonhosted.org/packages/e7/15/cee9e63ed9ac5bfc1a3ae8fc6c02c41745023c21eed622eef142d8fdd749/websockets-15.0-cp312-cp312-win32.whl", hash = "sha256:32e02a2d83f4954aa8c17e03fe8ec6962432c39aca4be7e8ee346b05a3476904", size = 175678 },
{ url = "https://files.pythonhosted.org/packages/4e/00/993974c60f40faabb725d4dbae8b072ef73b4c4454bd261d3b1d34ace41f/websockets-15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc02b159b65c05f2ed9ec176b715b66918a674bd4daed48a9a7a590dd4be1aa", size = 176119 },
{ url = "https://files.pythonhosted.org/packages/12/23/be28dc1023707ac51768f848d28a946443041a348ee3a54abdf9f6283372/websockets-15.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d2244d8ab24374bed366f9ff206e2619345f9cd7fe79aad5225f53faac28b6b1", size = 174714 },
{ url = "https://files.pythonhosted.org/packages/8f/ff/02b5e9fbb078e7666bf3d25c18c69b499747a12f3e7f2776063ef3fb7061/websockets-15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a302241fbe825a3e4fe07666a2ab513edfdc6d43ce24b79691b45115273b5e7", size = 172374 },
{ url = "https://files.pythonhosted.org/packages/8e/61/901c8d4698e0477eff4c3c664d53f898b601fa83af4ce81946650ec2a4cb/websockets-15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10552fed076757a70ba2c18edcbc601c7637b30cdfe8c24b65171e824c7d6081", size = 172605 },
{ url = "https://files.pythonhosted.org/packages/d2/4b/dc47601a80dff317aecf8da7b4ab278d11d3494b2c373b493e4887561f90/websockets-15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c53f97032b87a406044a1c33d1e9290cc38b117a8062e8a8b285175d7e2f99c9", size = 182380 },
{ url = "https://files.pythonhosted.org/packages/83/f7/b155d2b38f05ed47a0b8de1c9ea245fcd7fc625d89f35a37eccba34b42de/websockets-15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1caf951110ca757b8ad9c4974f5cac7b8413004d2f29707e4d03a65d54cedf2b", size = 181325 },
{ url = "https://files.pythonhosted.org/packages/d3/ff/040a20c01c294695cac0e361caf86f33347acc38f164f6d2be1d3e007d9f/websockets-15.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf1ab71f9f23b0a1d52ec1682a3907e0c208c12fef9c3e99d2b80166b17905f", size = 181763 },
{ url = "https://files.pythonhosted.org/packages/cb/6a/af23e93678fda8341ac8775e85123425e45c608389d3514863c702896ea5/websockets-15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bfcd3acc1a81f106abac6afd42327d2cf1e77ec905ae11dc1d9142a006a496b6", size = 182097 },
{ url = "https://files.pythonhosted.org/packages/7e/3e/1069e159c30129dc03c01513b5830237e576f47cedb888777dd885cae583/websockets-15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c8c5c8e1bac05ef3c23722e591ef4f688f528235e2480f157a9cfe0a19081375", size = 181485 },
{ url = "https://files.pythonhosted.org/packages/9a/a7/c91c47103f1cd941b576bbc452601e9e01f67d5c9be3e0a9abe726491ab5/websockets-15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:86bfb52a9cfbcc09aba2b71388b0a20ea5c52b6517c0b2e316222435a8cdab72", size = 181466 },
{ url = "https://files.pythonhosted.org/packages/16/32/a4ca6e3d56c24aac46b0cf5c03b841379f6409d07fc2044b244f90f54105/websockets-15.0-cp313-cp313-win32.whl", hash = "sha256:26ba70fed190708551c19a360f9d7eca8e8c0f615d19a574292b7229e0ae324c", size = 175673 },
{ url = "https://files.pythonhosted.org/packages/c0/31/25a417a23e985b61ffa5544f9facfe4a118cb64d664c886f1244a8baeca5/websockets-15.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae721bcc8e69846af00b7a77a220614d9b2ec57d25017a6bbde3a99473e41ce8", size = 176115 },
{ url = "https://files.pythonhosted.org/packages/e8/b2/31eec524b53f01cd8343f10a8e429730c52c1849941d1f530f8253b6d934/websockets-15.0-py3-none-any.whl", hash = "sha256:51ffd53c53c4442415b613497a34ba0aa7b99ac07f1e4a62db5dcd640ae6c3c3", size = 169023 },
]
[[package]]