mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bcbeacdd2 | |||
| 093dab5f03 | |||
| 0d7b0b3d7e | |||
| 93cc6379eb | |||
| 37a01b806d | |||
| a573f78317 | |||
| ab36e7e560 | |||
| 959b278687 | |||
| 04b387c742 | |||
| 0c59fd5e83 | |||
| 2ed920aa50 | |||
| bd20185492 | |||
| f731a23e49 | |||
| 07a9415451 | |||
| 20d0375ffa | |||
| eb4a55a5e7 | |||
| 51b4eb32c5 | |||
| 74adae506e | |||
| 94394f0bfe | |||
| 9e3f71cb87 | |||
| f4b703e57f | |||
| 6b8cefcd45 | |||
| 57984aa912 | |||
| f5a7541da1 | |||
| bc9ca0744f | |||
| 2c8ed1737d | |||
| 02f8e86692 | |||
| 0123544556 | |||
| 00d23a5ee1 | |||
| 812136c8c2 | |||
| 3e8e3e8961 | |||
| 8544bb7966 | |||
| 66b57e682f | |||
| 58a1296111 | |||
| 6da143898b |
+110
@@ -1,6 +1,116 @@
|
||||
# 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
|
||||
|
||||
- Add logfire instrumentation to tools
|
||||
([`3e8e3e8`](https://github.com/basicmachines-co/basic-memory/commit/3e8e3e8961eae2e82839746e28963191b0aef0a0))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`00d23a5`](https://github.com/basicmachines-co/basic-memory/commit/00d23a5ee15ddac4ea45e702dcd02ab9f0509276))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`812136c`](https://github.com/basicmachines-co/basic-memory/commit/812136c8c22ad191d14ff32dcad91aae076d4120))
|
||||
|
||||
- Search query pagination params
|
||||
([`bc9ca07`](https://github.com/basicmachines-co/basic-memory/commit/bc9ca0744ffe4296d7d597b4dd9b7c73c2d63f3f))
|
||||
|
||||
### Chores
|
||||
|
||||
- Fix tests
|
||||
([`57984aa`](https://github.com/basicmachines-co/basic-memory/commit/57984aa912625dcde7877afb96d874c164af2896))
|
||||
|
||||
- Remove unused tests
|
||||
([`2c8ed17`](https://github.com/basicmachines-co/basic-memory/commit/2c8ed1737d6769fe1ef5c96f8a2bd75b9899316a))
|
||||
|
||||
### Features
|
||||
|
||||
- Add cli commands for mcp tools
|
||||
([`f5a7541`](https://github.com/basicmachines-co/basic-memory/commit/f5a7541da17e97403b7a702720a05710f68b223a))
|
||||
|
||||
- Add pagination to build_context and recent_activity
|
||||
([`0123544`](https://github.com/basicmachines-co/basic-memory/commit/0123544556513af943d399d70b849b142b834b15))
|
||||
|
||||
- Add pagination to read_notes
|
||||
([`02f8e86`](https://github.com/basicmachines-co/basic-memory/commit/02f8e866923d5793d2620076c709c920d99f2c4f))
|
||||
|
||||
|
||||
## v0.6.0 (2025-02-18)
|
||||
|
||||
### Chores
|
||||
|
||||
- Re-add sync status console on watch
|
||||
([`66b57e6`](https://github.com/basicmachines-co/basic-memory/commit/66b57e682f2e9c432bffd4af293b0d1db1d3469b))
|
||||
|
||||
### Features
|
||||
|
||||
- Configure logfire telemetry ([#12](https://github.com/basicmachines-co/basic-memory/pull/12),
|
||||
[`6da1438`](https://github.com/basicmachines-co/basic-memory/commit/6da143898bd45cdab8db95b5f2b75810fbb741ba))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.5.0 (2025-02-18)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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.
|
||||
@@ -1,10 +1,10 @@
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win check
|
||||
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
|
||||
test:
|
||||
pytest -p pytest_mock -v
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
lint:
|
||||
ruff check . --fix
|
||||
@@ -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,4 +40,15 @@ installer-win:
|
||||
|
||||
|
||||
update-deps:
|
||||
uv lock f--upgrade
|
||||
uv lock --upgrade
|
||||
|
||||
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)"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -49,7 +49,14 @@ def update_claude_config():
|
||||
config = {"mcpServers": {}}
|
||||
|
||||
# Add/update basic-memory config
|
||||
config["mcpServers"]["basic-memory"] = {"command": "uvx", "args": ["basic-memory", "mcp"]}
|
||||
config["mcpServers"]["basic-memory"] = {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory@latest", "mcp"],
|
||||
"env": {
|
||||
"BASIC_MEMORY_ENV": "user",
|
||||
"LOGFIRE_TOKEN": "n2Fpvn34LjKYq8TdF1ZrXMgdBPXGn4HfXy6tYghZ55dB",
|
||||
},
|
||||
}
|
||||
|
||||
# Write back config
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.5.0"
|
||||
version = "0.8.0"
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
@@ -29,6 +29,8 @@ dependencies = [
|
||||
"fastapi[standard]>=0.115.8",
|
||||
"alembic>=1.14.1",
|
||||
"qasync>=0.27.1",
|
||||
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
|
||||
"pillow>=11.1.0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.8.0"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
+51
@@ -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"])
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import logfire
|
||||
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
|
||||
@@ -15,7 +15,6 @@ from basic_memory.api.routers import knowledge, search, memory, resource
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
logger.info(f"Starting Basic Memory API {basic_memory.__version__}")
|
||||
await db.run_migrations(app_config)
|
||||
yield
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
@@ -30,6 +29,10 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
if app_config != "test":
|
||||
logfire.instrument_fastapi(app)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
@@ -94,11 +94,8 @@ async def get_entity(
|
||||
try:
|
||||
entity = await entity_service.get_by_permalink(permalink)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(f"response: get_entity with result={result}")
|
||||
return result
|
||||
except EntityNotFoundError:
|
||||
logger.error(f"Error: Entity with {permalink} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
|
||||
|
||||
|
||||
@@ -114,8 +111,6 @@ async def get_entities(
|
||||
result = EntityListResponse(
|
||||
entities=[EntityResponse.model_validate(entity) for entity in entities]
|
||||
)
|
||||
|
||||
logger.info(f"response: get_entities with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -135,17 +130,15 @@ async def delete_entity(
|
||||
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if entity is None:
|
||||
logger.info("response: delete_entity with result=DeleteEntitiesResponse(deleted=False)")
|
||||
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)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entity with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -166,5 +159,4 @@ async def delete_entities(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entities with result={result}")
|
||||
return result
|
||||
|
||||
@@ -24,39 +24,37 @@ from basic_memory.services.context_service import ContextResultRow
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
|
||||
# return results
|
||||
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}")
|
||||
@@ -66,7 +64,11 @@ async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
metadata = MemoryMetadata.model_validate(context["metadata"])
|
||||
# Transform to GraphContext
|
||||
return GraphContext(
|
||||
primary_results=primary_results, related_results=related_results, metadata=metadata
|
||||
primary_results=primary_results,
|
||||
related_results=related_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +79,9 @@ async def recent(
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
@@ -87,16 +91,22 @@ async def recent(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, max_results=max_results
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
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
|
||||
@@ -109,21 +119,27 @@ async def get_memory_context(
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, max_results=max_results
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
@@ -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,24 +14,27 @@ 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"])
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> list[int]:
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return [item.id]
|
||||
return {item.id}
|
||||
case SearchItemType.OBSERVATION:
|
||||
return [item.entity_id] # pyright: ignore [reportReturnType]
|
||||
return {item.entity_id} # pyright: ignore [reportReturnType]
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = item.from_id
|
||||
to_entity = item.to_id # pyright: ignore [reportReturnType]
|
||||
return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType]
|
||||
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
@@ -44,6 +48,8 @@ async def get_resource_content(
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> FileResponse:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
@@ -52,6 +58,10 @@ async def get_resource_content(
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
results = [entity] if entity else []
|
||||
|
||||
# pagination for multiple results
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# search using the identifier as a permalink
|
||||
if not results:
|
||||
# if the identifier contains a wildcard, use GLOB search
|
||||
@@ -60,13 +70,13 @@ async def get_resource_content(
|
||||
if "*" in identifier
|
||||
else SearchQuery(permalink=identifier)
|
||||
)
|
||||
search_results = await search_service.search(query)
|
||||
search_results = await search_service.search(query, limit, offset)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the entities related to the search results
|
||||
entity_ids = [id for result in search_results for id in get_entity_ids(result)]
|
||||
results = await entity_service.get_entities_by_id(entity_ids)
|
||||
# get the deduplicated entities related to the search results
|
||||
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
|
||||
results = await entity_service.get_entities_by_id(list(entity_ids))
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
@@ -88,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"
|
||||
@@ -116,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,27 +2,35 @@
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
|
||||
from basic_memory.deps import get_search_service
|
||||
from basic_memory.deps import SearchServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SearchResponse)
|
||||
async def search(query: SearchQuery, search_service: SearchService = Depends(get_search_service)):
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceDep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
results = await search_service.search(query)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
|
||||
return SearchResponse(results=search_results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(
|
||||
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
|
||||
):
|
||||
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
|
||||
"""Recreate and populate the search index."""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
|
||||
@@ -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") # pragma: no cover
|
||||
|
||||
asyncio.run(db.run_migrations(config))
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
@@ -13,13 +15,14 @@ def reset(
|
||||
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
|
||||
): # pragma: no cover
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated, Set, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -209,7 +210,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
def import_chatgpt(
|
||||
conversations_json: Annotated[
|
||||
Path, typer.Option(..., help="Path to ChatGPT conversations.json file")
|
||||
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
|
||||
] = Path("conversations.json"),
|
||||
folder: Annotated[
|
||||
str, typer.Option(help="The folder to place the files in.")
|
||||
@@ -225,35 +226,38 @@ def import_chatgpt(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(
|
||||
f"\nImporting chats from {conversations_json}...writing to {base_path}"
|
||||
)
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -178,34 +179,35 @@ def import_claude(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Annotated, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -160,36 +161,36 @@ def import_projects(
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -113,32 +114,33 @@ def memory_json(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -3,35 +3,23 @@
|
||||
import asyncio
|
||||
from typing import Set, Dict
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
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
|
||||
):
|
||||
@@ -103,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
|
||||
@@ -134,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()
|
||||
@@ -146,9 +134,11 @@ def status(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
try:
|
||||
sync_service = asyncio.run(get_file_change_scanner())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
sync_service = asyncio.run(get_sync_service())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
@@ -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]")
|
||||
@@ -151,7 +148,7 @@ def display_detailed_sync_results(knowledge: SyncReport):
|
||||
console.print(knowledge_tree)
|
||||
|
||||
|
||||
async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
|
||||
"""Run sync operation."""
|
||||
|
||||
sync_service = await get_sync_service()
|
||||
@@ -163,7 +160,9 @@ async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
file_service=sync_service.entity_service.file_service,
|
||||
config=config,
|
||||
)
|
||||
await watch_service.handle_changes(config.home)
|
||||
# full sync
|
||||
await sync_service.sync(config.home)
|
||||
# watch changes
|
||||
await watch_service.run() # pragma: no cover
|
||||
else:
|
||||
# one time sync
|
||||
@@ -197,7 +196,7 @@ def sync(
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
from basic_memory.mcp.tools import get_entity as mcp_get_entity
|
||||
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, SearchItemType
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def write_note(
|
||||
title: Annotated[str, typer.Option(help="The title of the note")],
|
||||
content: Annotated[str, typer.Option(help="The content of the note")],
|
||||
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
|
||||
tags: Annotated[
|
||||
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
|
||||
] = None,
|
||||
):
|
||||
try:
|
||||
note = asyncio.run(mcp_write_note(title, content, folder, tags))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during write_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def read_note(identifier: str, page: int = 1, page_size: int = 10):
|
||||
try:
|
||||
note = asyncio.run(mcp_read_note(identifier, page, page_size))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during read_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
):
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_build_context(
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
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)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def recent_activity(
|
||||
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,
|
||||
):
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_recent_activity(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
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)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def search(
|
||||
query: str,
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
after_date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
if permalink and title: # pragma: no cover
|
||||
print("Cannot search both permalink and title")
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
search_query = SearchQuery(
|
||||
permalink_match=query if permalink 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_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
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def get_entity(identifier: str):
|
||||
try:
|
||||
entity = asyncio.run(mcp_get_entity(identifier=identifier))
|
||||
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,7 +1,6 @@
|
||||
"""Main CLI entry point for basic-memory.""" # pragma: no cover
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
from basic_memory.utils import setup_logging # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
@@ -13,11 +12,8 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tools,
|
||||
)
|
||||
|
||||
|
||||
# Set up logging when module is imported
|
||||
setup_logging(log_file=".basic-memory/basic-memory-cli.log") # pragma: no cover
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app()
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"""Configuration management for basic-memory."""
|
||||
|
||||
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"
|
||||
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class ProjectConfig(BaseSettings):
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
# Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
|
||||
home: Path = Field(
|
||||
default_factory=lambda: Path.home() / "basic-memory",
|
||||
@@ -26,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_",
|
||||
@@ -55,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__}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import logfire
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
@@ -43,7 +44,10 @@ async def get_engine_factory(
|
||||
project_config: ProjectConfigDep,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get engine and session maker."""
|
||||
return await db.get_or_create_db(project_config.database_path)
|
||||
engine, session_maker = await db.get_or_create_db(project_config.database_path)
|
||||
if project_config.env != "test":
|
||||
logfire.instrument_sqlalchemy(engine=engine)
|
||||
return engine, session_maker
|
||||
|
||||
|
||||
EngineFactoryDep = Annotated[
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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", []))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Knowledge graph management tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post
|
||||
from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.schemas.request import (
|
||||
GetEntitiesRequest,
|
||||
)
|
||||
@@ -16,15 +18,17 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Get complete information about a specific entity including observations and relations",
|
||||
)
|
||||
async def get_entity(permalink: Permalink) -> EntityResponse:
|
||||
async def get_entity(identifier: str) -> EntityResponse:
|
||||
"""Get a specific entity info by its permalink.
|
||||
|
||||
Args:
|
||||
permalink: Path identifier for the entity
|
||||
identifier: Path identifier for the entity
|
||||
"""
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
with logfire.span("Getting entity", permalink=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
permalink = memory_url_path(identifier)
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -39,11 +43,16 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
Returns:
|
||||
EntityListResponse containing complete details for each requested entity
|
||||
"""
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client, url, params=[("permalink", permalink) for permalink in request.permalinks]
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
with logfire.span("Getting multiple entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client,
|
||||
url,
|
||||
params=[
|
||||
("permalink", memory_url_path(identifier)) for identifier in request.permalinks
|
||||
],
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -51,6 +60,9 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
)
|
||||
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
|
||||
"""Delete entities from the knowledge graph."""
|
||||
url = "/knowledge/entities/delete"
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
with logfire.span("Deleting entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities/delete"
|
||||
|
||||
request.permalinks = [memory_url_path(permlink) for permlink in request.permalinks]
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""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
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -14,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(
|
||||
@@ -32,7 +34,9 @@ async def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -44,7 +48,9 @@ async def build_context(
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (1-3 recommended for performance)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -65,14 +71,21 @@ async def build_context(
|
||||
# Research the history of a feature
|
||||
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -88,10 +101,12 @@ 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",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
@@ -106,7 +121,9 @@ async def recent_activity(
|
||||
- Relative: "2 days ago", "last week", "yesterday"
|
||||
- Points in time: "2024-01-01", "January 1st"
|
||||
- Standard format: "7d", "24h"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -132,20 +149,29 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
|
||||
)
|
||||
params = {
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"max_results": max_results,
|
||||
}
|
||||
if type:
|
||||
params["type"] = type
|
||||
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
|
||||
)
|
||||
params = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
}
|
||||
if depth:
|
||||
params["depth"] = depth
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe # pyright: ignore
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
# send enum values if we have an enum, else send string value
|
||||
if type:
|
||||
params["type"] = [ # pyright: ignore
|
||||
type.value if isinstance(type, SearchItemType) else type for type in type
|
||||
]
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -7,6 +7,7 @@ while leveraging the underlying knowledge graph structure.
|
||||
from typing import Optional, List
|
||||
|
||||
from loguru import logger
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
@@ -60,75 +61,61 @@ async def write_note(
|
||||
- Observation counts by category
|
||||
- Relation counts (resolved/unresolved)
|
||||
- Tags if present
|
||||
|
||||
Examples:
|
||||
write_note(
|
||||
title="Search Implementation",
|
||||
content="# Search Component\\n\\n"
|
||||
"Implementation of the search feature, building on [[Core Search]].\\n\\n"
|
||||
"## Observations\\n"
|
||||
"- [tech] Using FTS5 for full-text search #implementation\\n"
|
||||
"- [design] Need pagination support #todo\\n\\n"
|
||||
"## Relations\\n"
|
||||
"- implements [[Search Spec]]\\n"
|
||||
"- depends_on [[Database Schema]]",
|
||||
folder="docs/components"
|
||||
)
|
||||
"""
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# 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"permalink: {result.permalink}",
|
||||
]
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
summary = [
|
||||
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
|
||||
f"permalink: {result.permalink}",
|
||||
]
|
||||
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
|
||||
return "\n".join(summary)
|
||||
return "\n".join(summary)
|
||||
|
||||
|
||||
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
|
||||
async def read_note(identifier: str) -> str:
|
||||
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
"""Get note content in unified diff format.
|
||||
|
||||
The content is returned in a unified diff inspired format:
|
||||
@@ -146,6 +133,8 @@ async def read_note(identifier: str) -> str:
|
||||
- Note permalink ("docs/example")
|
||||
- Relation path ("docs/example/depends-on/other-doc")
|
||||
- Pattern match ("docs/*-architecture")
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
Document content in unified diff format. For single documents, returns
|
||||
@@ -180,10 +169,13 @@ async def read_note(identifier: str) -> str:
|
||||
- Last modified timestamp
|
||||
- Content checksum
|
||||
"""
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
response = await call_get(client, f"/resource/{url}")
|
||||
return response.text
|
||||
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
response = await call_get(
|
||||
client, f"/resource/{url}", params={"page": page, "page_size": page_size}
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
@@ -203,6 +195,7 @@ async def delete_note(identifier: str) -> bool:
|
||||
# Delete by permalink
|
||||
delete_note("notes/project-planning")
|
||||
"""
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -11,7 +12,7 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Search across all content in basic-memory, including documents and entities",
|
||||
)
|
||||
async def search(query: SearchQuery) -> SearchResponse:
|
||||
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
|
||||
"""Search across all content in basic-memory.
|
||||
|
||||
Args:
|
||||
@@ -20,10 +21,18 @@ async def search(query: SearchQuery) -> SearchResponse:
|
||||
- types: Optional list of content types to search ("document" or "entity")
|
||||
- entity_types: Optional list of entity types to filter by
|
||||
- after_date: Optional date filter for recent content
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
SearchResponse with search results and metadata
|
||||
"""
|
||||
logger.info(f"Searching for {query.text}")
|
||||
response = await call_post(client, "/search/", json=query.model_dump())
|
||||
return SearchResponse.model_validate(response.json())
|
||||
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Searching for {query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
"/search/",
|
||||
json=query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -114,6 +115,7 @@ class SearchRepository:
|
||||
after_date: Optional[datetime] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
@@ -169,6 +171,7 @@ class SearchRepository:
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
@@ -194,6 +197,7 @@ class SearchRepository:
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.debug(f"Search {sql} params: {params}")
|
||||
@@ -262,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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
@@ -111,3 +120,6 @@ class GraphContext(BaseModel):
|
||||
|
||||
# Context metadata
|
||||
metadata: MemoryMetadata
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 1
|
||||
|
||||
@@ -51,7 +51,7 @@ class GetEntitiesRequest(BaseModel):
|
||||
discovered through search.
|
||||
"""
|
||||
|
||||
permalinks: Annotated[List[Permalink], MinLen(1)]
|
||||
permalinks: Annotated[List[Permalink], MinLen(1), MaxLen(10)]
|
||||
|
||||
|
||||
class CreateRelationsRequest(BaseModel):
|
||||
|
||||
@@ -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
|
||||
@@ -102,6 +103,8 @@ class SearchResponse(BaseModel):
|
||||
"""Wrapper for search results."""
|
||||
|
||||
results: List[SearchResult]
|
||||
current_page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Schema for future advanced search endpoint
|
||||
|
||||
@@ -54,11 +54,13 @@ class ContextService:
|
||||
types: Optional[List[SearchItemType]] = None,
|
||||
depth: int = 1,
|
||||
since: Optional[datetime] = None,
|
||||
max_results: int = 10,
|
||||
limit=10,
|
||||
offset=0,
|
||||
max_related: int = 10,
|
||||
):
|
||||
"""Build rich context from a memory:// URI."""
|
||||
logger.debug(
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
|
||||
)
|
||||
|
||||
if memory_url:
|
||||
@@ -66,15 +68,21 @@ class ContextService:
|
||||
# Pattern matching - use search
|
||||
if "*" in path:
|
||||
logger.debug(f"Pattern search for '{path}'")
|
||||
primary = await self.search_repository.search(permalink_match=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink_match=path, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Direct lookup for exact path
|
||||
else:
|
||||
logger.debug(f"Direct lookup for '{path}'")
|
||||
primary = await self.search_repository.search(permalink=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink=path, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Build context for '{types}'")
|
||||
primary = await self.search_repository.search(types=types, after_date=since)
|
||||
primary = await self.search_repository.search(
|
||||
types=types, after_date=since, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
|
||||
@@ -83,7 +91,7 @@ class ContextService:
|
||||
|
||||
# Find related content
|
||||
related = await self.find_related(
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_results
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_related
|
||||
)
|
||||
logger.debug(f"Found {len(related)} related results")
|
||||
for r in related:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +52,7 @@ class SearchService:
|
||||
|
||||
logger.info("Reindex complete")
|
||||
|
||||
async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
|
||||
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Supports three modes:
|
||||
@@ -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
|
||||
@@ -84,6 +85,8 @@ class SearchService:
|
||||
types=query.types,
|
||||
entity_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -116,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.
|
||||
|
||||
@@ -134,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)
|
||||
@@ -158,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(
|
||||
@@ -167,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,
|
||||
},
|
||||
@@ -212,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,
|
||||
@@ -220,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,46 +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:
|
||||
@@ -57,109 +273,73 @@ class SyncService:
|
||||
)
|
||||
logger.debug(f"Deleting from search index: {permalinks}")
|
||||
for permalink in permalinks:
|
||||
await self.search_service.delete_by_permalink(permalink)
|
||||
if permalink:
|
||||
await self.search_service.delete_by_permalink(permalink)
|
||||
else:
|
||||
await self.search_service.delete_by_entity_id(entity.id)
|
||||
|
||||
async def sync(self, directory: Path) -> SyncReport:
|
||||
"""Sync knowledge files with database."""
|
||||
changes = await self.scanner.find_knowledge_changes(directory)
|
||||
logger.info(f"Found {changes.total_changes} knowledge changes")
|
||||
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)
|
||||
|
||||
# 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)
|
||||
async def resolve_relations(self):
|
||||
"""Try to resolve any unresolved relations"""
|
||||
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
for path in changes.deleted:
|
||||
await self.handle_entity_deletion(path)
|
||||
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)
|
||||
|
||||
# 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
|
||||
else:
|
||||
logger.debug(f"Updating entity_markdown: {path}")
|
||||
await self.entity_service.update_entity_and_observations(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
|
||||
# Second pass
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
logger.debug(f"Updating relations for: {path}")
|
||||
|
||||
# Process relations
|
||||
checksum = changes.checksums[path]
|
||||
entity = await self.entity_service.update_entity_relations(Path(path), entity_markdown)
|
||||
|
||||
# add to search index
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
# Set final checksum to mark sync complete
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
# 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:
|
||||
# ignore reference to self
|
||||
if resolved_entity and resolved_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
# update search index
|
||||
await self.search_service.index_entity(target_entity)
|
||||
await self.search_service.index_entity(resolved_entity)
|
||||
|
||||
return changes
|
||||
async def scan_directory(self, directory: Path) -> ScanResult:
|
||||
"""
|
||||
Scan directory for markdown files and their checksums.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
|
||||
Returns:
|
||||
ScanResult containing found files and any errors
|
||||
"""
|
||||
|
||||
logger.debug(f"Scanning directory: {directory}")
|
||||
result = ScanResult()
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Utility functions for basic-memory."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -9,7 +10,9 @@ from typing import Optional, Union
|
||||
from loguru import logger
|
||||
from unidecode import unidecode
|
||||
|
||||
from basic_memory.config import config
|
||||
import basic_memory
|
||||
|
||||
import logfire
|
||||
|
||||
|
||||
def generate_permalink(file_path: Union[Path, str]) -> str:
|
||||
@@ -61,20 +64,46 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
|
||||
return "/".join(clean_segments)
|
||||
|
||||
|
||||
def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None) -> None:
|
||||
def setup_logging(
|
||||
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.
|
||||
:param home_dir: the root directory for the application
|
||||
:param log_file: the name of the log file to write to
|
||||
:param app: the fastapi application instance
|
||||
:param console: whether to log to the console
|
||||
"""
|
||||
|
||||
# Remove default handler and any existing handlers
|
||||
logger.remove()
|
||||
|
||||
# Add file handler
|
||||
if log_file:
|
||||
# Add file handler if we are not running tests
|
||||
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__,
|
||||
),
|
||||
environment=env,
|
||||
console=False,
|
||||
)
|
||||
logger.configure(handlers=[logfire.loguru_handler()])
|
||||
|
||||
# instrument code spans
|
||||
logfire.instrument_sqlite3()
|
||||
logfire.instrument_httpx()
|
||||
|
||||
# setup logger
|
||||
log_path = home_dir / log_file
|
||||
logger.add(
|
||||
str(log_path), # loguru expects a string path
|
||||
level=config.log_level,
|
||||
str(log_path),
|
||||
level=log_level,
|
||||
rotation="100 MB",
|
||||
retention="10 days",
|
||||
backtrace=True,
|
||||
@@ -83,5 +112,19 @@ def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None)
|
||||
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: '{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 |
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
@@ -26,6 +26,25 @@ async def test_get_memory_context(client, test_graph):
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pagination(client, test_graph):
|
||||
"""Test getting context from memory URL."""
|
||||
response = await client.get("/memory/test/root?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert context.primary_results[0].permalink == "test/root"
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata.uri == "test/root"
|
||||
assert context.metadata.depth == 1 # default depth
|
||||
# assert context.metadata["timeframe"] == "7d" # default timeframe
|
||||
assert isinstance(context.metadata.generated_at, datetime)
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pattern(client, test_graph):
|
||||
"""Test getting context with pattern matching."""
|
||||
@@ -91,6 +110,17 @@ async def test_recent_activity(client, test_graph):
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_pagination(client, test_graph):
|
||||
"""Test handling of paths."""
|
||||
response = await client.get("/memory/recent?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_by_type(client, test_graph):
|
||||
"""Test handling of non-existent paths."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for resource router endpoints."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
@@ -37,6 +38,35 @@ async def test_get_resource_content(client, test_config, entity_repository):
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink with pagination."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(test_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(f"/resource/{entity.permalink}", params={"page": 1, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_by_title(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink."""
|
||||
@@ -179,6 +209,54 @@ async def test_get_resource_entities(client, test_config, entity_repository):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_entities_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
assert entity1
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting second result
|
||||
response = await client.get("/resource/test/*", params={"page": 2, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Related Entity
|
||||
type: test
|
||||
permalink: test/related-entity
|
||||
---
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_relation(client, test_config, entity_repository):
|
||||
"""Test getting content by relation permalink."""
|
||||
@@ -226,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
|
||||
|
||||
@@ -35,6 +35,18 @@ async def test_search_basic(client, indexed_entity):
|
||||
assert found, "Expected to find indexed entity in results"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic_pagination(client, indexed_entity):
|
||||
"""Test basic text search."""
|
||||
response = await client.post("/search/?page=3&page_size=1", json={"text": "search"})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
assert search_results.current_page == 3
|
||||
assert search_results.page_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client, indexed_entity):
|
||||
"""Test search with type filter."""
|
||||
@@ -92,28 +104,6 @@ async def test_search_with_date_filter(client, indexed_entity):
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.skip("search scoring is not implemented yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scoring(client, indexed_entity):
|
||||
"""Test search result scoring."""
|
||||
# Exact match should score higher
|
||||
exact_response = await client.post("/search/", json={"text": "TestComponent"})
|
||||
|
||||
# Partial match should score lower
|
||||
partial_response = await client.post("/search/", json={"text": "test"})
|
||||
|
||||
assert exact_response.status_code == 200
|
||||
assert partial_response.status_code == 200
|
||||
|
||||
exact_result = SearchResponse.model_validate(exact_response.json())
|
||||
partial_result = SearchResponse.model_validate(partial_response.json())
|
||||
|
||||
exact_score = exact_result.results[0].score
|
||||
partial_score = partial_result.results[0].score
|
||||
|
||||
assert exact_score > partial_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty(search_service, client):
|
||||
"""Test search with no matches."""
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""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
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.commands.tools import tool_app
|
||||
from basic_memory.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_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 cli_env(test_config, client):
|
||||
pass
|
||||
|
||||
|
||||
@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]]
|
||||
""")
|
||||
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
entity_type="note",
|
||||
content=note_content,
|
||||
)
|
||||
)
|
||||
|
||||
# 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(cli_env, test_config):
|
||||
"""Test write_note command with basic arguments."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"CLI Test Note",
|
||||
"--content",
|
||||
"This is a CLI test note",
|
||||
"--folder",
|
||||
"test",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, test_config):
|
||||
"""Test write_note command with tags."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"Tagged CLI Test Note",
|
||||
"--content",
|
||||
"This is a test note with tags",
|
||||
"--folder",
|
||||
"test",
|
||||
"--tags",
|
||||
"tag1",
|
||||
"--tags",
|
||||
"tag2",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test read_note command."""
|
||||
permalink = setup_test_note["permalink"]
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["read-note", permalink],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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_search_basic(cli_env, setup_test_note):
|
||||
"""Test basic search command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test observation"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test search with permalink flag."""
|
||||
permalink = setup_test_note["permalink"]
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", permalink, "--permalink"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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_build_context(cli_env, setup_test_note):
|
||||
"""Test build_context command."""
|
||||
permalink = setup_test_note["permalink"]
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["build-context", f"memory://{permalink}"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test build_context command with all options."""
|
||||
permalink = setup_test_note["permalink"]
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"build-context",
|
||||
f"memory://{permalink}",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"1d",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test get_entity command."""
|
||||
permalink = setup_test_note["permalink"]
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["get-entity", permalink],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test recent_activity command with defaults."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["recent-activity"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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(cli_env, setup_test_note):
|
||||
"""Test recent_activity command with options."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"recent-activity",
|
||||
"--type",
|
||||
"entity",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"7d",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 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
|
||||
@@ -220,7 +220,7 @@ async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
|
||||
def test_import_chatgpt_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import", "chatgpt", "--conversations-json", str(nonexistent)])
|
||||
result = runner.invoke(app, ["import", "chatgpt", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
@@ -231,9 +231,7 @@ def test_import_chatgpt_command_success(tmp_path, sample_chatgpt_json, monkeypat
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
import_app, ["chatgpt", "--conversations-json", str(sample_chatgpt_json)]
|
||||
)
|
||||
result = runner.invoke(import_app, ["chatgpt", str(sample_chatgpt_json)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Imported 1 conversations" in result.output
|
||||
@@ -246,7 +244,7 @@ def test_import_chatgpt_command_invalid_json(tmp_path):
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(import_app, ["chatgpt", "--conversations-json", str(invalid_file)])
|
||||
result = runner.invoke(import_app, ["chatgpt", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
@@ -263,7 +261,6 @@ def test_import_chatgpt_with_custom_folder(tmp_path, sample_chatgpt_json, monkey
|
||||
[
|
||||
"import",
|
||||
"chatgpt",
|
||||
"--conversations-json",
|
||||
str(sample_chatgpt_json),
|
||||
"--folder",
|
||||
conversations_folder,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -33,6 +33,30 @@ async def test_get_single_entity(client):
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_entity_memory_url(client):
|
||||
"""Test retrieving a single entity."""
|
||||
# First create an entity
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Get the entity
|
||||
entity = await get_entity("memory://test/test-note")
|
||||
|
||||
# Verify entity details
|
||||
assert entity.title == "Test Note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multiple_entities(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
@@ -59,6 +83,34 @@ async def test_get_multiple_entities(client):
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multiple_entities_memory_ur(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
# Create two test entities
|
||||
await notes.write_note(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="# Test 1",
|
||||
)
|
||||
await notes.write_note(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="# Test 2",
|
||||
)
|
||||
|
||||
# Get both entities
|
||||
request = GetEntitiesRequest(
|
||||
permalinks=["memory://test/test-note-1", "memory://test/test-note-2"]
|
||||
)
|
||||
response = await get_entities(request)
|
||||
|
||||
# Verify we got both entities
|
||||
assert len(response.entities) == 2
|
||||
permalinks = {e.permalink for e in response.entities}
|
||||
assert "test/test-note-1" in permalinks
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities(client):
|
||||
"""Test deleting entities."""
|
||||
@@ -81,6 +133,28 @@ async def test_delete_entities(client):
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities_memory_url(client):
|
||||
"""Test deleting entities."""
|
||||
# Create a test entity
|
||||
await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note to Delete",
|
||||
)
|
||||
|
||||
# Delete the entity
|
||||
request = DeleteEntitiesRequest(permalinks=["memory://test/test-note"])
|
||||
response = await delete_entities(request)
|
||||
|
||||
# Verify deletion
|
||||
assert response.deleted is True
|
||||
|
||||
# Verify entity no longer exists
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_entity(client):
|
||||
"""Test attempting to get a non-existent entity."""
|
||||
|
||||
@@ -89,7 +89,9 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await recent_activity(type=["entity"], timeframe=timeframe, max_results=1)
|
||||
result = await recent_activity(
|
||||
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
@@ -136,7 +138,9 @@ async def test_build_context_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await build_context(url=test_url, timeframe=timeframe, max_results=1)
|
||||
result = await build_context(
|
||||
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
|
||||
@@ -222,6 +222,35 @@ async def test_multiple_notes(app):
|
||||
assert "Content 3" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_notes_pagination(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("test/note-3", "Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await notes.read_note(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once with pagination
|
||||
result = await notes.read_note("test/*", page=1, page_size=2)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_existing(app):
|
||||
"""Test deleting a new note.
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,27 @@ async def test_search_basic(client):
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_pagination(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await notes.write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
response = await search(query, page=1, page_size=1)
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) == 1
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client):
|
||||
"""Test search with entity type filter."""
|
||||
|
||||
@@ -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("") == ""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
@@ -112,7 +117,7 @@ def test_search_response():
|
||||
metadata={},
|
||||
),
|
||||
]
|
||||
response = SearchResponse(results=results)
|
||||
response = SearchResponse(results=results, current_page=1, page_size=1)
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].score > response.results[1].score
|
||||
|
||||
|
||||
@@ -128,18 +128,6 @@ async def test_build_context(context_service, test_graph):
|
||||
assert total_results == len(primary_results) + len(related_results)
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_pattern(context_service, test_graph):
|
||||
"""Test permalink wildcard lookup."""
|
||||
# url = memory_url.validate_strings("memory://test/connected*")
|
||||
# results = await context_service.build_context(url)
|
||||
# matched_results = results["metadata"]["matched_results"]
|
||||
# primary_results = results["primary_results"]
|
||||
# related_results = results["related_results"]
|
||||
# total_results = results["metadata"]["total_results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_not_found(context_service):
|
||||
"""Test handling non-existent permalinks."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -92,14 +107,6 @@ async def test_exact_title_match(link_resolver, test_entities):
|
||||
assert entity.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Fuzzy misspelling not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_match_misspelling(link_resolver):
|
||||
# Test slight misspelling
|
||||
result = await link_resolver.resolve_link("Core Servise")
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_partial_match(link_resolver):
|
||||
# Test partial match
|
||||
@@ -139,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"
|
||||
|
||||
@@ -19,6 +19,25 @@ async def test_search_permalink(search_service, test_graph):
|
||||
assert "test/root" in r.permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_limit_offset(search_service, test_graph):
|
||||
"""Exact permalink"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"))
|
||||
assert len(results) > 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=1)
|
||||
assert len(results) == 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=100)
|
||||
num_results = len(results)
|
||||
|
||||
# assert offset
|
||||
offset_results = await search_service.search(
|
||||
SearchQuery(permalink_match="test/*"), limit=100, offset=1
|
||||
)
|
||||
assert len(offset_results) == num_results - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_observations_wildcard(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user