mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce7a29f35 | |||
| 3f4d9e4d87 | |||
| 00c8633cfc | |||
| 617e60bda4 | |||
| 6c19c9edf5 | |||
| 9bff1f732e | |||
| 248214cb11 | |||
| 40ea28b0bf | |||
| 43cbb7b38c | |||
| 7930ddb291 | |||
| 1844e58210 | |||
| 7fbb5ebfc5 | |||
| 0e5a465b10 | |||
| 9d581cee13 | |||
| f58852954b | |||
| edd426b99f | |||
| 6b4a421315 | |||
| 4632405446 | |||
| 3a6d5ff58d | |||
| 03de85ec32 | |||
| 084c91ecce | |||
| 3d45227b72 | |||
| 069c0a21c6 | |||
| b27827671d | |||
| 0743ade5fc | |||
| f1c95709cb | |||
| 3c68b7d5dd |
@@ -0,0 +1,16 @@
|
||||
name: Claude Code Integration
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [ created ]
|
||||
pull_request_review_comment:
|
||||
types: [ created ]
|
||||
|
||||
jobs:
|
||||
claude-integration:
|
||||
uses: basicmachines-co/claude-code-github-action/.github/workflows/claude-full.yml@v0.11.0
|
||||
with:
|
||||
issue-label: 'claude-fix' # Optional: customize the trigger label
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLAUDE_TOKEN }}
|
||||
@@ -5,6 +5,12 @@ on:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
# pull_request_target runs on the BASE of the PR, not the merge result.
|
||||
# It has write permissions and access to secrets.
|
||||
# It's useful for PRs from forks or automated PRs but requires careful use for security reasons.
|
||||
# See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
|
||||
pull_request_target:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
+5
-1
@@ -47,4 +47,8 @@ ENV/
|
||||
# obsidian docs:
|
||||
/docs/.obsidian/
|
||||
/examples/.obsidian/
|
||||
/examples/.basic-memory/
|
||||
/examples/.basic-memory/
|
||||
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
@@ -1,6 +1,93 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v0.12.0 (2025-04-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- [bug] `#` character accumulation in markdown frontmatter tags prop
|
||||
([#79](https://github.com/basicmachines-co/basic-memory/pull/79),
|
||||
[`6c19c9e`](https://github.com/basicmachines-co/basic-memory/commit/6c19c9edf5131054ba201a109b37f15c83ef150c))
|
||||
|
||||
- [bug] Cursor has errors calling search tool
|
||||
([#78](https://github.com/basicmachines-co/basic-memory/pull/78),
|
||||
[`9d581ce`](https://github.com/basicmachines-co/basic-memory/commit/9d581cee133f9dde4a0a85118868227390c84161))
|
||||
|
||||
- [bug] Some notes never exit "modified" status
|
||||
([#77](https://github.com/basicmachines-co/basic-memory/pull/77),
|
||||
[`7930ddb`](https://github.com/basicmachines-co/basic-memory/commit/7930ddb2919057be30ceac8c4c19da6aaa1d3e92))
|
||||
|
||||
- [bug] write_note Tool Fails to Update Existing Files in Some Situations.
|
||||
([#80](https://github.com/basicmachines-co/basic-memory/pull/80),
|
||||
[`9bff1f7`](https://github.com/basicmachines-co/basic-memory/commit/9bff1f732e71bc60f88b5c2ce3db5a2aa60b8e28))
|
||||
|
||||
- Set default mcp log level to ERROR
|
||||
([#81](https://github.com/basicmachines-co/basic-memory/pull/81),
|
||||
[`248214c`](https://github.com/basicmachines-co/basic-memory/commit/248214cb114a269ca60ff6398e382f9e2495ad8e))
|
||||
|
||||
- Write_note preserves frontmatter fields in content
|
||||
([#84](https://github.com/basicmachines-co/basic-memory/pull/84),
|
||||
[`3f4d9e4`](https://github.com/basicmachines-co/basic-memory/commit/3f4d9e4d872ebc0ed719c61b24d803c14a9db5e6))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add VS Code instructions to README
|
||||
([#76](https://github.com/basicmachines-co/basic-memory/pull/76),
|
||||
[`43cbb7b`](https://github.com/basicmachines-co/basic-memory/commit/43cbb7b38cc0482ac0a41b6759320e3588186e43))
|
||||
|
||||
- Updated basicmachines.co links to be https
|
||||
([#69](https://github.com/basicmachines-co/basic-memory/pull/69),
|
||||
[`40ea28b`](https://github.com/basicmachines-co/basic-memory/commit/40ea28b0bfc60012924a69ecb76511daa4c7d133))
|
||||
|
||||
### Features
|
||||
|
||||
- Add watch to mcp process ([#83](https://github.com/basicmachines-co/basic-memory/pull/83),
|
||||
[`00c8633`](https://github.com/basicmachines-co/basic-memory/commit/00c8633cfcee75ff640ff8fe81dafeb956281a94))
|
||||
|
||||
- Permalink enhancements ([#82](https://github.com/basicmachines-co/basic-memory/pull/82),
|
||||
[`617e60b`](https://github.com/basicmachines-co/basic-memory/commit/617e60bda4a590678a5f551f10a73e7b47e3b13e))
|
||||
|
||||
- Avoiding "useless permalink values" for files without metadata - Enable permalinks to be updated
|
||||
on move via config setting
|
||||
|
||||
|
||||
## v0.11.0 (2025-03-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Just delete db for reset db instead of using migrations.
|
||||
([#65](https://github.com/basicmachines-co/basic-memory/pull/65),
|
||||
[`0743ade`](https://github.com/basicmachines-co/basic-memory/commit/0743ade5fc07440f95ecfd816ba7e4cfd74bca12))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
- Make logs for each process - mcp, sync, cli
|
||||
([#64](https://github.com/basicmachines-co/basic-memory/pull/64),
|
||||
[`f1c9570`](https://github.com/basicmachines-co/basic-memory/commit/f1c95709cbffb1b88292547b0b8f29fcca22d186))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update broken "Multiple Projects" link in README.md
|
||||
([#55](https://github.com/basicmachines-co/basic-memory/pull/55),
|
||||
[`3c68b7d`](https://github.com/basicmachines-co/basic-memory/commit/3c68b7d5dd689322205c67637dca7d188111ee6b))
|
||||
|
||||
### Features
|
||||
|
||||
- Add bm command alias for basic-memory
|
||||
([#67](https://github.com/basicmachines-co/basic-memory/pull/67),
|
||||
[`069c0a2`](https://github.com/basicmachines-co/basic-memory/commit/069c0a21c630784e1bf47d2b7de5d6d1f6fadd7a))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
- Rename search tool to search_notes
|
||||
([#66](https://github.com/basicmachines-co/basic-memory/pull/66),
|
||||
[`b278276`](https://github.com/basicmachines-co/basic-memory/commit/b27827671dc010be3e261b8b221aca6b7f836661))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
|
||||
## v0.10.1 (2025-03-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -107,7 +107,7 @@ See the [README.md](README.md) file for a project overview.
|
||||
1d", "1 week")
|
||||
|
||||
**Search & Discovery:**
|
||||
- `search(query, page, page_size)` - Full-text search across all content with filtering options
|
||||
- `search_notes(query, page, page_size)` - Full-text search across all content with filtering options
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
@@ -115,7 +115,7 @@ See the [README.md](README.md) file for a project overview.
|
||||
- MCP Prompts for better AI interaction:
|
||||
- `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants
|
||||
- `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context
|
||||
- `search(query, after_date)` - Search with detailed, formatted results for better context understanding
|
||||
- `search_notes(query, after_date)` - Search with detailed, formatted results for better context understanding
|
||||
- `recent_activity(timeframe)` - View recently changed items with formatted output
|
||||
- `json_canvas_spec()` - Full JSON Canvas specification for Obsidian visualization
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ Basic Memory lets you build persistent knowledge through natural conversations w
|
||||
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
|
||||
enable any compatible LLM to read and write to your local knowledge base.
|
||||
|
||||
- Website: http://basicmachines.co
|
||||
- Documentation: http://memory.basicmachines.co
|
||||
- Website: https://basicmachines.co
|
||||
- Documentation: https://memory.basicmachines.co
|
||||
|
||||
## Pick up your conversation right where you left off
|
||||
|
||||
@@ -262,6 +262,43 @@ Examples of relations:
|
||||
- documented_in [[Coffee Journal]]
|
||||
```
|
||||
|
||||
## Using with VS Code
|
||||
For one-click installation, click one of the install buttons below...
|
||||
|
||||
[](https://insiders.vscode.dev/redirect/mcp/install?name=basic-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22basic-memory%22%2C%22mcp%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=basic-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22basic-memory%22%2C%22mcp%22%5D%7D&quality=insiders)
|
||||
|
||||
You can use Basic Memory with VS Code to easily retrieve and store information while coding. Click the installation buttons above for one-click setup, or follow the manual installation instructions below.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"basic-memory": {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"basic-memory": {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using with Claude Desktop
|
||||
|
||||
Basic Memory is built using the MCP (Model Context Protocol) and works with the Claude desktop app (https://claude.ai/):
|
||||
@@ -285,7 +322,8 @@ for OS X):
|
||||
}
|
||||
```
|
||||
|
||||
If you want to use a specific project (see [Multiple Projects](#multiple-projects) below), update your Claude Desktop
|
||||
If you want to use a specific project (see [Multiple Projects](docs/User%20Guide.md#multiple-projects)), update your
|
||||
Claude Desktop
|
||||
config:
|
||||
|
||||
```json
|
||||
@@ -320,7 +358,7 @@ basic-memory sync --watch
|
||||
write_note(title, content, folder, tags) - Create or update notes
|
||||
read_note(identifier, page, page_size) - Read notes by title or permalink
|
||||
build_context(url, depth, timeframe) - Navigate knowledge graph via memory:// URLs
|
||||
search(query, page, page_size) - Search across your knowledge base
|
||||
search_notes(query, page, page_size) - Search across your knowledge base
|
||||
recent_activity(type, depth, timeframe) - Find recently updated information
|
||||
canvas(nodes, edges, title, folder) - Generate knowledge visualizations
|
||||
```
|
||||
@@ -361,4 +399,4 @@ and submitting PRs.
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
Built with ♥️ by Basic Machines
|
||||
Built with ♥️ by Basic Machines
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
Starting issue-fix mode at Sat Apr 5 18:02:54 UTC 2025
|
||||
Fetching issue #75 details
|
||||
Using repository: basicmachines-co/basic-memory
|
||||
Checking if phernandez is a member of organization basicmachines-co
|
||||
User phernandez is a member of organization basicmachines-co. Proceeding with Claude fix.
|
||||
Creating a new branch: fix-issue-75-20250405180254
|
||||
From https://github.com/basicmachines-co/basic-memory
|
||||
* branch main -> FETCH_HEAD
|
||||
Switched to a new branch 'fix-issue-75-20250405180254'
|
||||
branch 'fix-issue-75-20250405180254' set up to track 'origin/main'.
|
||||
Prompt saved to ./claude-output/claude_prompt_75.txt for debugging
|
||||
Running Claude to fix the issue...
|
||||
Committing changes...
|
||||
@@ -0,0 +1,35 @@
|
||||
Let's summarize the changes we've made to fix issue #75:
|
||||
|
||||
1. We updated the `search_notes` tool in `/src/basic_memory/mcp/tools/search.py` to accept primitive types as parameters instead of a complex Pydantic `SearchQuery` object. This makes it easier for LLMs like Cursor to make proper tool calls.
|
||||
|
||||
2. We converted the internal implementation to create a SearchQuery object from the primitive parameters, maintaining backward compatibility with the existing API.
|
||||
|
||||
3. We updated tests in `/tests/mcp/test_tool_search.py` to use the new function signature with primitive parameters.
|
||||
|
||||
4. We updated code in `/src/basic_memory/mcp/tools/read_note.py` to use the new function signature when making calls to `search_notes`.
|
||||
|
||||
5. We updated code in `/src/basic_memory/mcp/prompts/search.py` to use the new function signature when making calls to `search_notes`.
|
||||
|
||||
These changes should make it easier for Cursor and other LLMs to use the search_notes tool by eliminating the complex Pydantic object parameter in favor of simple primitive parameters.
|
||||
|
||||
---SUMMARY---
|
||||
Fixed issue #75 where Cursor was having errors calling the search_notes tool. The problem was that the search_notes tool was expecting a complex Pydantic object (SearchQuery) as input, which was confusing Cursor.
|
||||
|
||||
Changes:
|
||||
1. Modified the search_notes tool to accept primitive types (strings, lists, etc.) as parameters instead of a complex Pydantic object
|
||||
2. Updated the implementation to create a SearchQuery object internally from these primitive parameters
|
||||
3. Updated all call sites in the codebase that were using the old function signature
|
||||
4. Updated tests to use the new function signature
|
||||
|
||||
The fix makes it easier for LLMs like Cursor to make proper calls to the search_notes tool, which will resolve the reported error messages:
|
||||
- "Parameter 'query' must be of type undefined, got object"
|
||||
- "Parameter 'query' must be of type undefined, got string"
|
||||
- "Invalid type for parameter 'query' in tool search_notes"
|
||||
|
||||
Files modified:
|
||||
- src/basic_memory/mcp/tools/search.py
|
||||
- src/basic_memory/mcp/tools/read_note.py
|
||||
- src/basic_memory/mcp/prompts/search.py
|
||||
- tests/mcp/test_tool_search.py
|
||||
- tests/mcp/test_tool_read_note.py
|
||||
---END SUMMARY---
|
||||
@@ -0,0 +1,49 @@
|
||||
You are Claude, an AI assistant tasked with fixing issues in a GitHub repository.
|
||||
|
||||
Issue #75: [BUG] Cursor has errors calling search tool
|
||||
|
||||
Issue Description:
|
||||
## Bug Description
|
||||
|
||||
|
||||
|
||||
> Cursor cannot figure out how to structure the parameters for that tool call. No matter what Cursor seems to try it gets the errors.
|
||||
>
|
||||
> ```Looking at the error messages more carefully:
|
||||
> - When I pass an object: "Parameter 'query' must be of type undefined, got object"
|
||||
> - When I pass a string: "Parameter 'query' must be of type undefined, got string"
|
||||
>
|
||||
>
|
||||
>
|
||||
> and then it reports: "Invalid type for parameter 'query' in tool search_notes"
|
||||
> Any chance you can give me some guidance with this?
|
||||
>
|
||||
|
||||
## Steps To Reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
try using search tool in Cursor.
|
||||
|
||||
## Possible Solution
|
||||
|
||||
The tool args should probably be plain text and not json to make it easier to call.
|
||||
Additional Instructions from User Comment:
|
||||
let make a PR to implement option #1.
|
||||
Your task is to:
|
||||
1. Analyze the issue carefully to understand the problem
|
||||
2. Look through the repository to identify the relevant files that need to be modified
|
||||
3. Make precise changes to fix the issue
|
||||
4. Use the Edit tool to modify files directly when needed
|
||||
5. Be minimal in your changes - only modify what's necessary to fix the issue
|
||||
|
||||
After making changes, provide a summary of what you did in this format:
|
||||
|
||||
---SUMMARY---
|
||||
[Your detailed summary of changes, including which files were modified and how]
|
||||
---END SUMMARY---
|
||||
|
||||
Remember:
|
||||
- Be specific in your changes
|
||||
- Only modify files that are necessary to fix the issue
|
||||
- Follow existing code style and conventions
|
||||
- Make the minimal changes needed to resolve the issue
|
||||
@@ -53,7 +53,7 @@ content = await read_note("specs/search-design") # By path
|
||||
content = await read_note("memory://specs/search") # By memory URL
|
||||
|
||||
# Searching for knowledge
|
||||
results = await search(
|
||||
results = await search_notes(
|
||||
query="authentication system", # Text to search for
|
||||
page=1, # Optional: Pagination
|
||||
page_size=10 # Optional: Results per page
|
||||
@@ -154,7 +154,7 @@ Users will interact with Basic Memory in patterns like:
|
||||
Human: "What were our decisions about auth?"
|
||||
|
||||
You: Let me find that information for you.
|
||||
[Use search() to find relevant notes]
|
||||
[Use search_notes() to find relevant notes]
|
||||
[Then build_context() to understand connections]
|
||||
```
|
||||
|
||||
@@ -251,7 +251,7 @@ When creating relations, you can:
|
||||
# Example workflow for creating notes with effective relations
|
||||
async def create_note_with_effective_relations():
|
||||
# Search for existing entities to reference
|
||||
search_results = await search("travel")
|
||||
search_results = await search_notes("travel")
|
||||
existing_entities = [result.title for result in search_results.primary_results]
|
||||
|
||||
# Check if specific entities exist
|
||||
@@ -323,7 +323,7 @@ Common issues to watch for:
|
||||
content = await read_note("Document")
|
||||
except:
|
||||
# Try search instead
|
||||
results = await search("Document")
|
||||
results = await search_notes("Document")
|
||||
if results and results.primary_results:
|
||||
# Found something similar
|
||||
content = await read_note(results.primary_results[0].permalink)
|
||||
@@ -369,7 +369,7 @@ Common issues to watch for:
|
||||
- **Create deliberate relations**: Connect each note to at least 2-3 related entities
|
||||
- **Use existing entities**: Before creating a new relation, search for existing entities
|
||||
- **Verify wikilinks**: When referencing `[[Entity]]`, use exact titles of existing notes
|
||||
- **Check accuracy**: Use `search()` or `recent_activity()` to confirm entity titles
|
||||
- **Check accuracy**: Use `search_notes()` or `recent_activity()` to confirm entity titles
|
||||
- **Use precise relation types**: Choose specific relation types that convey meaning (e.g., "implements" instead of "relates_to")
|
||||
- **Consider bidirectional relations**: When appropriate, create inverse relations in both entities
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
BMCP <-->|"write_note() read_note()"| KnowledgeFiles
|
||||
BMCP <-->|"search() build_context()"| KnowledgeIndex
|
||||
BMCP <-->|"search_notes() build_context()"| KnowledgeIndex
|
||||
KnowledgeFiles <-.->|Sync Process| KnowledgeIndex
|
||||
KnowledgeFiles <-->|Direct Editing| Editors((Text Editors & Git))
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
@@ -40,6 +40,7 @@ Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
|
||||
|
||||
[project.scripts]
|
||||
basic-memory = "basic_memory.cli.main:app"
|
||||
bm = "basic_memory.cli.main:app"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.10.1"
|
||||
__version__ = "0.12.0"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""FastAPI application for basic-memory knowledge graph API."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -7,16 +8,54 @@ from fastapi.exception_handlers import http_exception_handler
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import config as app_config
|
||||
from basic_memory.api.routers import knowledge, search, memory, resource, project_info
|
||||
from basic_memory.api.routers import knowledge, memory, project_info, resource, search
|
||||
from basic_memory.config import config as project_config
|
||||
from basic_memory.config import config_manager
|
||||
from basic_memory.sync import SyncService, WatchService
|
||||
|
||||
|
||||
async def run_background_sync(sync_service: SyncService, watch_service: WatchService): # pragma: no cover
|
||||
logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
|
||||
# full sync
|
||||
await sync_service.sync(project_config.home, show_progress=False)
|
||||
|
||||
# watch changes
|
||||
await watch_service.run()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
await db.run_migrations(app_config)
|
||||
await db.run_migrations(project_config)
|
||||
|
||||
# app config
|
||||
basic_memory_config = config_manager.load_config()
|
||||
logger.info(f"Sync changes enabled: {basic_memory_config.sync_changes}")
|
||||
logger.info(f"Update permalinks on move enabled: {basic_memory_config.update_permalinks_on_move}")
|
||||
|
||||
watch_task = None
|
||||
if basic_memory_config.sync_changes:
|
||||
# import after migrations have run
|
||||
from basic_memory.cli.commands.sync import get_sync_service
|
||||
|
||||
sync_service = await get_sync_service()
|
||||
watch_service = WatchService(
|
||||
sync_service=sync_service,
|
||||
file_service=sync_service.entity_service.file_service,
|
||||
config=project_config,
|
||||
)
|
||||
watch_task = asyncio.create_task(run_background_sync(sync_service, watch_service))
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping watch service.")
|
||||
|
||||
|
||||
# proceed with startup
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
if watch_task:
|
||||
watch_task.cancel()
|
||||
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import config
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Show version and exit."""
|
||||
@@ -58,9 +54,6 @@ def app_callback(
|
||||
config = new_config
|
||||
|
||||
|
||||
# Run database migrations
|
||||
asyncio.run(db.run_migrations(config))
|
||||
|
||||
# Register sub-command groups
|
||||
import_app = typer.Typer(help="Import data from various sources")
|
||||
app.add_typer(import_app, name="import")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.alembic import migrations
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -14,7 +17,17 @@ def reset(
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
migrations.reset_database()
|
||||
# Get database path
|
||||
db_path = config.database_path
|
||||
|
||||
# Delete the database file if it exists
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
logger.info(f"Database file deleted: {db_path}")
|
||||
|
||||
# Create a new empty database
|
||||
asyncio.run(db.run_migrations(config))
|
||||
logger.info("Database reset complete")
|
||||
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
|
||||
@@ -4,7 +4,7 @@ from loguru import logger
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import config, config_manager
|
||||
|
||||
# Import mcp instance
|
||||
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
@@ -19,8 +19,15 @@ def mcp(): # pragma: no cover
|
||||
home_dir = config.home
|
||||
project_name = config.project
|
||||
|
||||
# app config
|
||||
basic_memory_config = config_manager.load_config()
|
||||
|
||||
logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
|
||||
logger.info(f"Project: {project_name}")
|
||||
logger.info(f"Project directory: {home_dir}")
|
||||
logger.info(f"Sync changes enabled: {basic_memory_config.sync_changes}")
|
||||
logger.info(
|
||||
f"Update permalinks on move enabled: {basic_memory_config.update_permalinks_on_move}"
|
||||
)
|
||||
|
||||
mcp_server.run()
|
||||
|
||||
@@ -70,6 +70,7 @@ async def get_sync_service(): # pragma: no cover
|
||||
|
||||
# Create sync service
|
||||
sync_service = SyncService(
|
||||
config=config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
|
||||
@@ -2,31 +2,29 @@
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Optional, List, Annotated
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
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 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.mcp.prompts.recent_activity import (
|
||||
recent_activity_prompt as recent_activity_prompt,
|
||||
)
|
||||
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
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_notes as mcp_search
|
||||
from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import MemoryUrl
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
|
||||
@@ -180,8 +178,8 @@ def recent_activity(
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def search(
|
||||
@tool_app.command("search-notes")
|
||||
def search_notes(
|
||||
query: str,
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
@@ -198,13 +196,28 @@ def search(
|
||||
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,
|
||||
if permalink and title: # pragma: no cover
|
||||
typer.echo(
|
||||
"Use either --permalink or --title, not both. Exiting.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# set search type
|
||||
search_type = ("permalink" if permalink else None,)
|
||||
search_type = ("permalink_match" if permalink and "*" in query else None,)
|
||||
search_type = ("title" if title else None,)
|
||||
search_type = "text" if search_type is None else search_type
|
||||
|
||||
results = asyncio.run(
|
||||
mcp_search(
|
||||
query,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
|
||||
# Use json module for more controlled serialization
|
||||
import json
|
||||
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
"""Main CLI entry point for basic-memory.""" # pragma: no cover
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
import asyncio
|
||||
|
||||
import typer
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
status,
|
||||
sync,
|
||||
db,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
import_chatgpt,
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tool,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
sync,
|
||||
tool,
|
||||
)
|
||||
from basic_memory.config import config
|
||||
from basic_memory.db import run_migrations as db_run_migrations
|
||||
|
||||
|
||||
# Version command
|
||||
@@ -55,4 +60,8 @@ def main(
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Run database migrations
|
||||
asyncio.run(db_run_migrations(config))
|
||||
|
||||
# start the app
|
||||
app()
|
||||
|
||||
+71
-12
@@ -5,12 +5,13 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.utils import setup_logging
|
||||
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"
|
||||
CONFIG_FILE_NAME = "config.json"
|
||||
@@ -37,7 +38,11 @@ class ProjectConfig(BaseSettings):
|
||||
default=500, description="Milliseconds to wait after changes before syncing", gt=0
|
||||
)
|
||||
|
||||
log_level: str = "DEBUG"
|
||||
# update permalinks on move
|
||||
update_permalinks_on_move: bool = Field(
|
||||
default=False,
|
||||
description="Whether to update permalinks when files are moved or renamed. default (False)",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
@@ -76,6 +81,18 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Name of the default project to use",
|
||||
)
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
update_permalinks_on_move: bool = Field(
|
||||
default=False,
|
||||
description="Whether to update permalinks when files are moved or renamed. default (False)",
|
||||
)
|
||||
|
||||
sync_changes: bool = Field(
|
||||
default=True,
|
||||
description="Whether to sync changes in real time. default (True)",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
@@ -193,9 +210,14 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
"BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
|
||||
)
|
||||
|
||||
update_permalinks_on_move = config_manager.load_config().update_permalinks_on_move
|
||||
try:
|
||||
project_path = config_manager.get_project_path(actual_project_name)
|
||||
return ProjectConfig(home=project_path, project=actual_project_name)
|
||||
return ProjectConfig(
|
||||
home=project_path,
|
||||
project=actual_project_name,
|
||||
update_permalinks_on_move=update_permalinks_on_move,
|
||||
)
|
||||
except ValueError: # pragma: no cover
|
||||
logger.warning(f"Project '{actual_project_name}' not found, using default")
|
||||
project_path = config_manager.get_project_path(config_manager.default_project)
|
||||
@@ -213,11 +235,48 @@ user_home = Path.home()
|
||||
log_dir = user_home / DATA_DIR_NAME
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
setup_logging(
|
||||
env=config.env,
|
||||
home_dir=user_home, # Use user home for logs
|
||||
log_level=config.log_level,
|
||||
log_file=f"{DATA_DIR_NAME}/basic-memory.log",
|
||||
console=False,
|
||||
)
|
||||
logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
||||
|
||||
def get_process_name(): # pragma: no cover
|
||||
"""
|
||||
get the type of process for logging
|
||||
"""
|
||||
import sys
|
||||
|
||||
if "sync" in sys.argv:
|
||||
return "sync"
|
||||
elif "mcp" in sys.argv:
|
||||
return "mcp"
|
||||
elif "cli" in sys.argv:
|
||||
return "cli"
|
||||
else:
|
||||
return "api"
|
||||
|
||||
|
||||
process_name = get_process_name()
|
||||
|
||||
# Global flag to track if logging has been set up
|
||||
_LOGGING_SETUP = False
|
||||
|
||||
|
||||
def setup_basic_memory_logging(): # pragma: no cover
|
||||
"""Set up logging for basic-memory, ensuring it only happens once."""
|
||||
global _LOGGING_SETUP
|
||||
if _LOGGING_SETUP:
|
||||
# We can't log before logging is set up
|
||||
# print("Skipping duplicate logging setup")
|
||||
return
|
||||
|
||||
setup_logging(
|
||||
env=config.env,
|
||||
home_dir=user_home, # Use user home for logs
|
||||
log_level=config_manager.load_config().log_level,
|
||||
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
||||
console=False,
|
||||
)
|
||||
|
||||
logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
||||
_LOGGING_SETUP = True
|
||||
|
||||
|
||||
# Set up logging
|
||||
setup_basic_memory_logging()
|
||||
|
||||
@@ -146,7 +146,9 @@ async def engine_session_factory(
|
||||
_session_maker = None
|
||||
|
||||
|
||||
async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM):
|
||||
async def run_migrations(
|
||||
app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM
|
||||
): # pragma: no cover
|
||||
"""Run any pending alembic migrations."""
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
|
||||
@@ -104,6 +104,9 @@ def has_frontmatter(content: str) -> bool:
|
||||
Returns:
|
||||
True if content has valid frontmatter markers (---), False otherwise
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
|
||||
content = content.strip()
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
|
||||
@@ -92,27 +92,36 @@ class EntityParser:
|
||||
async def parse_file(self, path: Path | str) -> EntityMarkdown:
|
||||
"""Parse markdown file into EntityMarkdown."""
|
||||
|
||||
absolute_path = self.base_path / path
|
||||
# Parse frontmatter and content using python-frontmatter
|
||||
post = frontmatter.load(str(absolute_path))
|
||||
# TODO move to api endpoint to check if absolute path was requested
|
||||
# Check if the path is already absolute
|
||||
if (
|
||||
isinstance(path, Path)
|
||||
and path.is_absolute()
|
||||
or (isinstance(path, str) and Path(path).is_absolute())
|
||||
):
|
||||
absolute_path = Path(path)
|
||||
else:
|
||||
absolute_path = self.base_path / path
|
||||
|
||||
# Parse frontmatter and content using python-frontmatter
|
||||
file_content = absolute_path.read_text()
|
||||
return await self.parse_file_content(absolute_path, file_content)
|
||||
|
||||
async def parse_file_content(self, absolute_path, file_content):
|
||||
post = frontmatter.loads(file_content)
|
||||
# Extract file stat info
|
||||
file_stats = absolute_path.stat()
|
||||
|
||||
metadata = post.metadata
|
||||
metadata["title"] = post.metadata.get("title", absolute_path.name)
|
||||
metadata["title"] = post.metadata.get("title", absolute_path.stem)
|
||||
metadata["type"] = post.metadata.get("type", "note")
|
||||
tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
|
||||
if tags:
|
||||
metadata["tags"] = tags
|
||||
|
||||
# frontmatter
|
||||
entity_frontmatter = EntityFrontmatter(
|
||||
metadata=post.metadata,
|
||||
)
|
||||
|
||||
entity_content = parse(post.content)
|
||||
|
||||
return EntityMarkdown(
|
||||
frontmatter=entity_frontmatter,
|
||||
content=post.content,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Utilities for converting between markdown and entity models."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from frontmatter import Post
|
||||
|
||||
from basic_memory.file_utils import has_frontmatter, remove_frontmatter
|
||||
from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
|
||||
from basic_memory.markdown import EntityMarkdown
|
||||
from basic_memory.models import Entity, Observation as ObservationModel
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.models import Observation as ObservationModel
|
||||
|
||||
|
||||
def entity_model_from_markdown(
|
||||
@@ -32,16 +32,13 @@ def entity_model_from_markdown(
|
||||
if not markdown.created or not markdown.modified: # pragma: no cover
|
||||
raise ValueError("Both created and modified dates are required in markdown")
|
||||
|
||||
# Generate permalink if not provided
|
||||
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
|
||||
|
||||
# Create or update entity
|
||||
model = entity or Entity()
|
||||
|
||||
# Update basic fields
|
||||
model.title = markdown.frontmatter.title
|
||||
model.entity_type = markdown.frontmatter.type
|
||||
model.permalink = permalink
|
||||
model.permalink = markdown.frontmatter.permalink
|
||||
model.file_path = str(file_path)
|
||||
model.content_type = "text/markdown"
|
||||
model.created_at = markdown.created
|
||||
@@ -77,22 +74,33 @@ async def schema_to_markdown(schema: Any) -> Post:
|
||||
"""
|
||||
# Extract content and metadata
|
||||
content = schema.content or ""
|
||||
frontmatter_metadata = dict(schema.entity_metadata or {})
|
||||
entity_metadata = dict(schema.entity_metadata or {})
|
||||
|
||||
# if the content contains frontmatter, remove it and merge
|
||||
if has_frontmatter(content):
|
||||
content_frontmatter = parse_frontmatter(content)
|
||||
content = remove_frontmatter(content)
|
||||
|
||||
# Merge content frontmatter with entity metadata
|
||||
# (entity_metadata takes precedence for conflicts)
|
||||
content_frontmatter.update(entity_metadata)
|
||||
entity_metadata = content_frontmatter
|
||||
|
||||
# Remove special fields for ordered frontmatter
|
||||
for field in ["type", "title", "permalink"]:
|
||||
frontmatter_metadata.pop(field, None)
|
||||
entity_metadata.pop(field, None)
|
||||
|
||||
# Create Post with ordered fields
|
||||
# Create Post with fields ordered by insert order
|
||||
post = Post(
|
||||
content,
|
||||
title=schema.title,
|
||||
type=schema.entity_type,
|
||||
permalink=schema.permalink,
|
||||
**frontmatter_metadata,
|
||||
)
|
||||
# set the permalink if passed in
|
||||
if schema.permalink:
|
||||
post.metadata["permalink"] = schema.permalink
|
||||
|
||||
if entity_metadata:
|
||||
post.metadata.update(entity_metadata)
|
||||
|
||||
return post
|
||||
|
||||
@@ -5,19 +5,19 @@ providing context from previous interactions to maintain continuity.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional, Annotated
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
|
||||
from basic_memory.mcp.prompts.utils import PromptContext, PromptContextItem, format_prompt_context
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.search import search
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
@@ -47,8 +47,8 @@ async def continue_conversation(
|
||||
|
||||
# If topic provided, search for it
|
||||
if topic:
|
||||
search_results = await search(
|
||||
SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY])
|
||||
search_results = await search_notes(
|
||||
query=topic, after_date=timeframe, entity_types=[SearchItemType.ENTITY]
|
||||
)
|
||||
|
||||
# Build context from results
|
||||
@@ -93,7 +93,7 @@ async def continue_conversation(
|
||||
## Next Steps
|
||||
|
||||
You can:
|
||||
- Explore more with: `search({{"text": "{topic}"}})`
|
||||
- Explore more with: `search_notes({{"text": "{topic}"}})`
|
||||
- See what's changed: `recent_activity(timeframe="{timeframe or "7d"}")`
|
||||
- **Record new learnings or decisions from this conversation:** `write_note(title="[Create a meaningful title]", content="[Content with observations and relations]")`
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ from loguru import logger
|
||||
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.mcp.tools.search import search_notes as search_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
@@ -40,7 +40,7 @@ async def search_prompt(
|
||||
"""
|
||||
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
|
||||
|
||||
search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
|
||||
search_results = await search_tool(query=query, after_date=timeframe)
|
||||
return format_search_results(query, search_results, timeframe)
|
||||
|
||||
|
||||
@@ -144,9 +144,9 @@ def format_search_results(
|
||||
## 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)`
|
||||
- Refine your search: `search_notes("{query} AND additional_term")`
|
||||
- Exclude terms: `search_notes("{query} NOT exclude_term")`
|
||||
- View more results: `search_notes("{query}", after_date=None)`
|
||||
- Check recent activity: `recent_activity()`
|
||||
|
||||
## Synthesize and Capture Knowledge
|
||||
|
||||
@@ -49,7 +49,7 @@ content = await read_note("specs/search-design") # By path
|
||||
content = await read_note("memory://specs/search") # By memory URL
|
||||
|
||||
# Searching for knowledge
|
||||
results = await search(
|
||||
results = await search_notes(
|
||||
query="authentication system", # Text to search for
|
||||
page=1, # Optional: Pagination
|
||||
page_size=10 # Optional: Results per page
|
||||
@@ -154,7 +154,7 @@ Users will interact with Basic Memory in patterns like:
|
||||
Human: "What were our decisions about auth?"
|
||||
|
||||
You: Let me find that information for you.
|
||||
[Use search() to find relevant notes]
|
||||
[Use search_notes() to find relevant notes]
|
||||
[Then build_context() to understand connections]
|
||||
```
|
||||
|
||||
@@ -263,7 +263,7 @@ When creating relations, you can:
|
||||
# Example workflow for creating notes with effective relations
|
||||
async def create_note_with_effective_relations():
|
||||
# Search for existing entities to reference
|
||||
search_results = await search("travel")
|
||||
search_results = await search_notes("travel")
|
||||
existing_entities = [result.title for result in search_results.primary_results]
|
||||
|
||||
# Check if specific entities exist
|
||||
@@ -335,7 +335,7 @@ Common issues to watch for:
|
||||
content = await read_note("Document")
|
||||
except:
|
||||
# Try search instead
|
||||
results = await search("Document")
|
||||
results = await search_notes("Document")
|
||||
if results and results.primary_results:
|
||||
# Found something similar
|
||||
content = await read_note(results.primary_results[0].permalink)
|
||||
@@ -381,7 +381,7 @@ Common issues to watch for:
|
||||
- **Create deliberate relations**: Connect each note to at least 2-3 related entities
|
||||
- **Use existing entities**: Before creating a new relation, search for existing entities
|
||||
- **Verify wikilinks**: When referencing `[[Entity]]`, use exact titles of existing notes
|
||||
- **Check accuracy**: Use `search()` or `recent_activity()` to confirm entity titles
|
||||
- **Check accuracy**: Use `search_notes()` or `recent_activity()` to confirm entity titles
|
||||
- **Use precise relation types**: Choose specific relation types that convey meaning (e.g., "implements" instead
|
||||
of "relates_to")
|
||||
- **Consider bidirectional relations**: When appropriate, create inverse relations in both entities
|
||||
|
||||
@@ -4,8 +4,8 @@ from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.utilities.logging import configure_logging
|
||||
|
||||
# mcp console logging
|
||||
configure_logging(level="INFO")
|
||||
configure_logging(level="ERROR")
|
||||
|
||||
|
||||
# Create the shared server instance
|
||||
mcp = FastMCP("Basic Memory")
|
||||
mcp = FastMCP("Basic Memory", log_level="ERROR")
|
||||
|
||||
@@ -12,7 +12,7 @@ from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.search import search
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.canvas import canvas
|
||||
|
||||
__all__ = [
|
||||
@@ -22,6 +22,6 @@ __all__ = [
|
||||
"read_content",
|
||||
"read_note",
|
||||
"recent_activity",
|
||||
"search",
|
||||
"search_notes",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -6,10 +6,9 @@ from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -63,7 +62,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await search(SearchQuery(title=identifier))
|
||||
title_results = await search_notes(query=identifier, search_type="title")
|
||||
|
||||
if title_results and title_results.results:
|
||||
result = title_results.results[0] # Get the first/best match
|
||||
@@ -87,7 +86,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await search(SearchQuery(text=identifier))
|
||||
text_results = await search_notes(query=identifier, search_type="text")
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
if not text_results or not text_results.results:
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
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_post
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base.",
|
||||
)
|
||||
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
|
||||
async def search_notes(
|
||||
query: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
types: Optional[List[str]] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
after_date: Optional[str] = None,
|
||||
) -> SearchResponse:
|
||||
"""Search across all content in the knowledge base.
|
||||
|
||||
This tool searches the knowledge base using full-text search, pattern matching,
|
||||
@@ -19,59 +29,85 @@ async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> Sear
|
||||
and date.
|
||||
|
||||
Args:
|
||||
query: SearchQuery object with search parameters including:
|
||||
- text: Full-text search (e.g., "project planning")
|
||||
Supports boolean operators: AND, OR, NOT and parentheses for grouping
|
||||
- title: Search only in titles (e.g., "Meeting notes")
|
||||
- permalink: Exact permalink match (e.g., "docs/meeting-notes")
|
||||
- permalink_match: Pattern matching for permalinks (e.g., "docs/*-notes")
|
||||
- types: Optional list of content types to search (e.g., ["entity", "observation"])
|
||||
- entity_types: Optional list of entity types to filter by (e.g., ["note", "person"])
|
||||
- after_date: Optional date filter for recent content (e.g., "1 week", "2d")
|
||||
query: The search query string
|
||||
page: The page number of results to return (default 1)
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text")
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d")
|
||||
|
||||
Returns:
|
||||
SearchResponse with results and pagination info
|
||||
|
||||
Examples:
|
||||
# Basic text search
|
||||
results = await search(SearchQuery(text="project planning"))
|
||||
results = await search_notes("project planning")
|
||||
|
||||
# Boolean AND search (both terms must be present)
|
||||
results = await search(SearchQuery(text="project AND planning"))
|
||||
results = await search_notes("project AND planning")
|
||||
|
||||
# Boolean OR search (either term can be present)
|
||||
results = await search(SearchQuery(text="project OR meeting"))
|
||||
results = await search_notes("project OR meeting")
|
||||
|
||||
# Boolean NOT search (exclude terms)
|
||||
results = await search(SearchQuery(text="project NOT meeting"))
|
||||
results = await search_notes("project NOT meeting")
|
||||
|
||||
# Boolean search with grouping
|
||||
results = await search(SearchQuery(text="(project OR planning) AND notes"))
|
||||
results = await search_notes("(project OR planning) AND notes")
|
||||
|
||||
# Search with type filter
|
||||
results = await search(SearchQuery(
|
||||
text="meeting notes",
|
||||
results = await search_notes(
|
||||
query="meeting notes",
|
||||
types=["entity"],
|
||||
))
|
||||
)
|
||||
|
||||
# Search with entity type filter, e.g., note vs
|
||||
results = await search_notes(
|
||||
query="meeting notes",
|
||||
types=["entity"],
|
||||
)
|
||||
|
||||
# Search for recent content
|
||||
results = await search(SearchQuery(
|
||||
text="bug report",
|
||||
results = await search_notes(
|
||||
query="bug report",
|
||||
after_date="1 week"
|
||||
))
|
||||
)
|
||||
|
||||
# Pattern matching on permalinks
|
||||
results = await search(SearchQuery(
|
||||
permalink_match="docs/meeting-*"
|
||||
))
|
||||
results = await search_notes(
|
||||
query="docs/meeting-*",
|
||||
search_type="permalink"
|
||||
)
|
||||
"""
|
||||
logger.info(f"Searching for {query}")
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
# Set the appropriate search field based on search_type
|
||||
if search_type == "text":
|
||||
search_query.text = query
|
||||
elif search_type == "title":
|
||||
search_query.title = query
|
||||
elif search_type == "permalink" and "*" in query:
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else:
|
||||
search_query.text = query # Default to text search
|
||||
|
||||
# Add optional filters if provided
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
|
||||
logger.info(f"Searching for {search_query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
"/search/",
|
||||
json=query.model_dump(),
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
@@ -88,8 +88,10 @@ async def write_note(
|
||||
# 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"# {action} note",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
# Count observations by category
|
||||
|
||||
@@ -4,10 +4,10 @@ import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text, Executable, Result
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
@@ -123,9 +123,9 @@ class SearchRepository:
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[SearchItemType]] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
entity_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -174,15 +174,15 @@ class SearchRepository:
|
||||
else:
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle entity type filter
|
||||
if entity_types:
|
||||
entity_type_list = ", ".join(f"'{t}'" for t in entity_types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({entity_type_list})")
|
||||
type_list = ", ".join(f"'{t.value}'" for t in entity_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
|
||||
@@ -49,8 +49,8 @@ class SearchQuery(BaseModel):
|
||||
title: Optional[str] = None # title only search
|
||||
|
||||
# Optional filters
|
||||
types: Optional[List[SearchItemType]] = None # Filter by item type
|
||||
entity_types: Optional[List[str]] = None # Filter by entity type
|
||||
types: Optional[List[str]] = None # Filter by type
|
||||
entity_types: Optional[List[SearchItemType]] = None # Filter by entity type
|
||||
after_date: Optional[Union[datetime, str]] = None # Time-based filter
|
||||
|
||||
@field_validator("after_date")
|
||||
|
||||
@@ -81,7 +81,7 @@ class ContextService:
|
||||
else:
|
||||
logger.debug(f"Build context for '{types}'")
|
||||
primary = await self.search_repository.search(
|
||||
types=types, after_date=since, limit=limit, offset=offset
|
||||
entity_types=types, after_date=since, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
"""Service for managing entities in the database."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Sequence, List, Optional, Tuple, Union
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import frontmatter
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from basic_memory.markdown import EntityMarkdown
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
|
||||
from basic_memory.models import Entity as EntityModel, Observation, Relation
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.models import Observation, Relation
|
||||
from basic_memory.repository import ObservationRepository, RelationRepository
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
|
||||
from basic_memory.services import FileService
|
||||
from basic_memory.services import BaseService
|
||||
from basic_memory.services import BaseService, FileService
|
||||
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
logger.debug(f"Creating or updating entity: {schema}")
|
||||
|
||||
# Try to find existing entity using smart resolution
|
||||
existing = await self.link_resolver.resolve_link(schema.permalink)
|
||||
existing = await self.link_resolver.resolve_link(schema.permalink or schema.file_path)
|
||||
|
||||
if existing:
|
||||
logger.debug(f"Found existing entity: {existing.permalink}")
|
||||
@@ -100,7 +100,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
async def create_entity(self, schema: EntitySchema) -> EntityModel:
|
||||
"""Create a new entity and write to filesystem."""
|
||||
logger.debug(f"Creating entity: {schema.permalink}")
|
||||
logger.debug(f"Creating entity: {schema.title}")
|
||||
|
||||
# Get file path and ensure it's a Path object
|
||||
file_path = Path(schema.file_path)
|
||||
@@ -230,7 +230,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
Creates the entity with null checksum to indicate sync not complete.
|
||||
Relations will be added in second pass.
|
||||
"""
|
||||
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
|
||||
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
|
||||
model = entity_model_from_markdown(file_path, markdown)
|
||||
|
||||
# Mark as incomplete because we still need to add relations
|
||||
@@ -315,7 +315,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
except IntegrityError:
|
||||
# Unique constraint violation - relation already exists
|
||||
logger.debug(
|
||||
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}, type: {rel.type}"
|
||||
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -46,10 +46,17 @@ class LinkResolver:
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
|
||||
# 3. Try file path
|
||||
found_path = await self.entity_repository.get_by_file_path(clean_text)
|
||||
if found_path:
|
||||
logger.debug(f"Found entity with path: {found_path.file_path}")
|
||||
return found_path
|
||||
|
||||
# search if indicated
|
||||
if use_search and "*" not in clean_text:
|
||||
# 3. Fall back to search for fuzzy matching on title
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
|
||||
query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
|
||||
if results:
|
||||
|
||||
@@ -181,17 +181,6 @@ class SearchService:
|
||||
Each type gets its own row in the search index with appropriate metadata.
|
||||
"""
|
||||
|
||||
if entity.permalink is None: # pragma: no cover
|
||||
logger.error(
|
||||
"Missing permalink for markdown entity",
|
||||
entity_id=entity.id,
|
||||
title=entity.title,
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
|
||||
)
|
||||
|
||||
content_stems = []
|
||||
content_snippet = ""
|
||||
title_variants = self._generate_variants(entity.title)
|
||||
@@ -202,22 +191,13 @@ class SearchService:
|
||||
content_stems.append(content)
|
||||
content_snippet = f"{content[:250]}"
|
||||
|
||||
content_stems.extend(self._generate_variants(entity.permalink))
|
||||
if entity.permalink:
|
||||
content_stems.extend(self._generate_variants(entity.permalink))
|
||||
|
||||
content_stems.extend(self._generate_variants(entity.file_path))
|
||||
|
||||
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
|
||||
|
||||
if entity.permalink is None: # pragma: no cover
|
||||
logger.error(
|
||||
"Missing permalink for markdown entity",
|
||||
entity_id=entity.id,
|
||||
title=entity.title,
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
|
||||
)
|
||||
|
||||
# Index entity
|
||||
await self.repository.index_item(
|
||||
SearchIndexRow(
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import Dict, Optional, Set, Tuple
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.file_utils import has_frontmatter
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.repository import EntityRepository, RelationRepository
|
||||
@@ -65,6 +67,7 @@ class SyncService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ProjectConfig,
|
||||
entity_service: EntityService,
|
||||
entity_parser: EntityParser,
|
||||
entity_repository: EntityRepository,
|
||||
@@ -72,6 +75,7 @@ class SyncService:
|
||||
search_service: SearchService,
|
||||
file_service: FileService,
|
||||
):
|
||||
self.config = config
|
||||
self.entity_service = entity_service
|
||||
self.entity_parser = entity_parser
|
||||
self.entity_repository = entity_repository
|
||||
@@ -327,43 +331,52 @@ class SyncService:
|
||||
"""
|
||||
# Parse markdown first to get any existing permalink
|
||||
logger.debug("Parsing markdown file", path=path)
|
||||
|
||||
file_path = self.entity_parser.base_path / path
|
||||
file_content = file_path.read_text()
|
||||
file_contains_frontmatter = has_frontmatter(file_content)
|
||||
|
||||
# entity markdown will always contain front matter, so it can be used up create/update the entity
|
||||
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 the file contains frontmatter, resolve a permalink
|
||||
if file_contains_frontmatter:
|
||||
# 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(
|
||||
"Updating permalink",
|
||||
path=path,
|
||||
old_permalink=entity_markdown.frontmatter.permalink,
|
||||
new_permalink=permalink,
|
||||
)
|
||||
# If permalink changed, update the file
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
logger.info(
|
||||
"Updating permalink",
|
||||
path=path,
|
||||
old_permalink=entity_markdown.frontmatter.permalink,
|
||||
new_permalink=permalink,
|
||||
)
|
||||
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
checksum = await self.file_service.update_frontmatter(path, {"permalink": permalink})
|
||||
else:
|
||||
checksum = await self.file_service.compute_checksum(path)
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
await self.file_service.update_frontmatter(path, {"permalink": permalink})
|
||||
|
||||
# if the file is new, create an entity
|
||||
if new:
|
||||
# Create entity with final permalink
|
||||
logger.debug("Creating new entity from markdown", path=path, permalink=permalink)
|
||||
|
||||
logger.debug("Creating new entity from markdown", path=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("Updating entity from markdown", path=path, permalink=permalink)
|
||||
|
||||
logger.debug("Updating entity from markdown", path=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)
|
||||
|
||||
# After updating relations, we need to compute the checksum again
|
||||
# This is necessary for files with wikilinks to ensure consistent checksums
|
||||
# after relation processing is complete
|
||||
final_checksum = await self.file_service.compute_checksum(path)
|
||||
|
||||
# set checksum
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
await self.entity_repository.update(entity.id, {"checksum": final_checksum})
|
||||
|
||||
logger.debug(
|
||||
"Markdown sync completed",
|
||||
@@ -371,9 +384,11 @@ class SyncService:
|
||||
entity_id=entity.id,
|
||||
observation_count=len(entity.observations),
|
||||
relation_count=len(entity.relations),
|
||||
checksum=final_checksum,
|
||||
)
|
||||
|
||||
return entity, checksum
|
||||
# Return the final checksum to ensure everything is consistent
|
||||
return entity, final_checksum
|
||||
|
||||
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
|
||||
"""Sync a non-markdown file with basic tracking.
|
||||
@@ -468,8 +483,30 @@ class SyncService:
|
||||
|
||||
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})
|
||||
# Update file_path in all cases
|
||||
updates = {"file_path": new_path}
|
||||
|
||||
# If configured, also update permalink to match new path
|
||||
if self.config.update_permalinks_on_move:
|
||||
# generate new permalink value
|
||||
new_permalink = await self.entity_service.resolve_permalink(new_path)
|
||||
|
||||
# write to file and get new checksum
|
||||
new_checksum = await self.file_service.update_frontmatter(
|
||||
new_path, {"permalink": new_permalink}
|
||||
)
|
||||
|
||||
updates["permalink"] = new_permalink
|
||||
updates["checksum"] = new_checksum
|
||||
|
||||
logger.info(
|
||||
"Updating permalink on move",
|
||||
old_permalink=entity.permalink,
|
||||
new_permalink=new_permalink,
|
||||
new_checksum=new_checksum,
|
||||
)
|
||||
|
||||
updated = await self.entity_repository.update(entity.id, updates)
|
||||
|
||||
if updated is None: # pragma: no cover
|
||||
logger.error(
|
||||
|
||||
@@ -138,15 +138,23 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
|
||||
|
||||
Returns:
|
||||
A list of tag strings, or an empty list if no tags
|
||||
|
||||
Note:
|
||||
This function strips leading '#' characters from tags to prevent
|
||||
their accumulation when tags are processed multiple times.
|
||||
"""
|
||||
if tags is None:
|
||||
return []
|
||||
|
||||
# Process list of tags
|
||||
if isinstance(tags, list):
|
||||
return tags
|
||||
# First strip whitespace, then strip leading '#' characters to prevent accumulation
|
||||
return [tag.strip().lstrip("#") for tag in tags if tag and tag.strip()]
|
||||
|
||||
# Process comma-separated string of tags
|
||||
if isinstance(tags, str):
|
||||
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
# Split by comma, strip whitespace, then strip leading '#' characters
|
||||
return [tag.strip().lstrip("#") for tag in tags.split(",") if tag and tag.strip()]
|
||||
|
||||
# For any other type, try to convert to string and parse
|
||||
try: # pragma: no cover
|
||||
|
||||
@@ -49,7 +49,7 @@ content = await read_note("specs/search-design") # By path
|
||||
content = await read_note("memory://specs/search") # By memory URL
|
||||
|
||||
# Searching for knowledge
|
||||
results = await search(
|
||||
results = await search_notes(
|
||||
query="authentication system", # Text to search for
|
||||
page=1, # Optional: Pagination
|
||||
page_size=10 # Optional: Results per page
|
||||
@@ -154,7 +154,7 @@ Users will interact with Basic Memory in patterns like:
|
||||
Human: "What were our decisions about auth?"
|
||||
|
||||
You: Let me find that information for you.
|
||||
[Use search() to find relevant notes]
|
||||
[Use search_notes() to find relevant notes]
|
||||
[Then build_context() to understand connections]
|
||||
```
|
||||
|
||||
@@ -263,7 +263,7 @@ When creating relations, you can:
|
||||
# Example workflow for creating notes with effective relations
|
||||
async def create_note_with_effective_relations():
|
||||
# Search for existing entities to reference
|
||||
search_results = await search("travel")
|
||||
search_results = await search_notes("travel")
|
||||
existing_entities = [result.title for result in search_results.primary_results]
|
||||
|
||||
# Check if specific entities exist
|
||||
@@ -335,7 +335,7 @@ Common issues to watch for:
|
||||
content = await read_note("Document")
|
||||
except:
|
||||
# Try search instead
|
||||
results = await search("Document")
|
||||
results = await search_notes("Document")
|
||||
if results and results.primary_results:
|
||||
# Found something similar
|
||||
content = await read_note(results.primary_results[0].permalink)
|
||||
@@ -381,7 +381,7 @@ Common issues to watch for:
|
||||
- **Create deliberate relations**: Connect each note to at least 2-3 related entities
|
||||
- **Use existing entities**: Before creating a new relation, search for existing entities
|
||||
- **Verify wikilinks**: When referencing `[[Entity]]`, use exact titles of existing notes
|
||||
- **Check accuracy**: Use `search()` or `recent_activity()` to confirm entity titles
|
||||
- **Check accuracy**: Use `search_notes()` or `recent_activity()` to confirm entity titles
|
||||
- **Use precise relation types**: Choose specific relation types that convey meaning (e.g., "implements" instead
|
||||
of "relates_to")
|
||||
- **Consider bidirectional relations**: When appropriate, create inverse relations in both entities
|
||||
|
||||
@@ -251,7 +251,7 @@ async def test_entity_indexing(client: AsyncClient):
|
||||
|
||||
# Verify it's searchable
|
||||
search_response = await client.post(
|
||||
"/search/", json={"text": "search", "types": [SearchItemType.ENTITY.value]}
|
||||
"/search/", json={"text": "search", "entity_types": [SearchItemType.ENTITY.value]}
|
||||
)
|
||||
assert search_response.status_code == 200
|
||||
search_result = SearchResponse.model_validate(search_response.json())
|
||||
@@ -279,7 +279,7 @@ async def test_entity_delete_indexing(client: AsyncClient):
|
||||
|
||||
# Verify it's initially searchable
|
||||
search_response = await client.post(
|
||||
"/search/", json={"text": "delete", "types": [SearchItemType.ENTITY.value]}
|
||||
"/search/", json={"text": "delete", "entity_types": [SearchItemType.ENTITY.value]}
|
||||
)
|
||||
search_result = SearchResponse.model_validate(search_response.json())
|
||||
assert len(search_result.results) == 1
|
||||
@@ -475,7 +475,7 @@ async def test_update_entity_search_index(client: AsyncClient):
|
||||
|
||||
# Search should find new content
|
||||
search_response = await client.post(
|
||||
"/search/", json={"text": "sphinx marker", "types": [SearchItemType.ENTITY.value]}
|
||||
"/search/", json={"text": "sphinx marker", "entity_types": [SearchItemType.ENTITY.value]}
|
||||
)
|
||||
results = search_response.json()["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
@@ -48,11 +48,11 @@ async def test_search_basic_pagination(client, indexed_entity):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client, indexed_entity):
|
||||
async def test_search_with_entity_type_filter(client, indexed_entity):
|
||||
"""Test search with type filter."""
|
||||
# Should find with correct type
|
||||
response = await client.post(
|
||||
"/search/", json={"text": "test", "types": [SearchItemType.ENTITY.value]}
|
||||
"/search/", json={"text": "test", "entity_types": [SearchItemType.ENTITY.value]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
@@ -60,7 +60,7 @@ async def test_search_with_type_filter(client, indexed_entity):
|
||||
|
||||
# Should find with relation type
|
||||
response = await client.post(
|
||||
"/search/", json={"text": "test", "types": [SearchItemType.RELATION.value]}
|
||||
"/search/", json={"text": "test", "entity_types": [SearchItemType.RELATION.value]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
@@ -68,16 +68,16 @@ async def test_search_with_type_filter(client, indexed_entity):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_entity_type_filter(client, indexed_entity):
|
||||
async def test_search_with_type_filter(client, indexed_entity):
|
||||
"""Test search with entity type filter."""
|
||||
# Should find with correct entity type
|
||||
response = await client.post("/search/", json={"text": "test", "entity_types": ["test"]})
|
||||
response = await client.post("/search/", json={"text": "test", "types": ["test"]})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
# Should not find with wrong entity type
|
||||
response = await client.post("/search/", json={"text": "test", "entity_types": ["note"]})
|
||||
response = await client.post("/search/", json={"text": "test", "types": ["note"]})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 0
|
||||
@@ -153,8 +153,8 @@ async def test_multiple_filters(client, indexed_entity):
|
||||
"/search/",
|
||||
json={
|
||||
"text": "test",
|
||||
"types": [SearchItemType.ENTITY.value],
|
||||
"entity_types": ["test"],
|
||||
"entity_types": [SearchItemType.ENTITY.value],
|
||||
"types": ["test"],
|
||||
"after_date": datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -211,7 +211,7 @@ def test_search_basic(cli_env, setup_test_note):
|
||||
"""Test basic search command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test observation"],
|
||||
["search-notes", "test observation"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -235,7 +235,7 @@ def test_search_permalink(cli_env, setup_test_note):
|
||||
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", permalink, "--permalink"],
|
||||
["search-notes", permalink, "--permalink"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
+4
-2
@@ -1,14 +1,14 @@
|
||||
"""Common test fixtures."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from loguru import logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig
|
||||
@@ -140,6 +140,7 @@ def entity_parser(test_config):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sync_service(
|
||||
test_config: ProjectConfig,
|
||||
entity_service: EntityService,
|
||||
entity_parser: EntityParser,
|
||||
entity_repository: EntityRepository,
|
||||
@@ -149,6 +150,7 @@ async def sync_service(
|
||||
) -> SyncService:
|
||||
"""Create sync service for testing."""
|
||||
return SyncService(
|
||||
config=test_config,
|
||||
entity_service=entity_service,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
|
||||
@@ -217,6 +217,37 @@ def test_parse_empty_content():
|
||||
assert len(result.relations) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_file_with_absolute_path(test_config, entity_parser):
|
||||
"""Test parsing a file with an absolute path."""
|
||||
content = dedent("""
|
||||
---
|
||||
type: component
|
||||
permalink: absolute_path_test
|
||||
---
|
||||
|
||||
# Absolute Path Test
|
||||
|
||||
A file with an absolute path.
|
||||
""")
|
||||
|
||||
# Create a test file in the project directory
|
||||
test_file = test_config.home / "absolute_path_test.md"
|
||||
test_file.write_text(content)
|
||||
|
||||
# Get the absolute path to the test file
|
||||
absolute_path = test_file.resolve()
|
||||
|
||||
# Parse the file using the absolute path
|
||||
entity = await entity_parser.parse_file(absolute_path)
|
||||
|
||||
# Verify the file was parsed correctly
|
||||
assert entity.frontmatter.permalink == "absolute_path_test"
|
||||
assert "Absolute Path Test" in entity.content
|
||||
assert entity.created is not None
|
||||
assert entity.modified is not None
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_parse_file_invalid_yaml(test_config, entity_parser):
|
||||
# """Test parsing file with invalid YAML frontmatter."""
|
||||
|
||||
@@ -26,7 +26,7 @@ async def mock_call_get():
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_search():
|
||||
"""Mock for search tool."""
|
||||
with patch("basic_memory.mcp.tools.read_note.search") as mock:
|
||||
with patch("basic_memory.mcp.tools.read_note.search_notes") as mock:
|
||||
# Default to empty results
|
||||
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
|
||||
yield mock
|
||||
@@ -51,8 +51,10 @@ async def test_note_unicode_content(app):
|
||||
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Unicode Test.md (272389cd)
|
||||
# Created note
|
||||
file_path: test/Unicode Test.md
|
||||
permalink: test/unicode-test
|
||||
checksum: 272389cd
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
@@ -201,7 +203,8 @@ async def test_read_note_title_search_fallback(mock_call_get, mock_search):
|
||||
|
||||
# Verify title search was used
|
||||
mock_search.assert_called_once()
|
||||
assert mock_search.call_args[0][0].title == "Test Note"
|
||||
assert mock_search.call_args[1]["query"] == "Test Note"
|
||||
assert mock_search.call_args[1]["search_type"] == "title"
|
||||
|
||||
# Verify second lookup was used
|
||||
assert mock_call_get.call_count == 2
|
||||
@@ -254,8 +257,10 @@ async def test_read_note_text_search_fallback(mock_call_get, mock_search):
|
||||
|
||||
# Verify both search types were used
|
||||
assert mock_search.call_count == 2
|
||||
assert mock_search.call_args_list[0][0][0].title == "some query" # Title search
|
||||
assert mock_search.call_args_list[1][0][0].text == "some query" # Text search
|
||||
assert mock_search.call_args_list[0][1]["query"] == "some query" # Title search
|
||||
assert mock_search.call_args_list[0][1]["search_type"] == "title"
|
||||
assert mock_search.call_args_list[1][1]["query"] == "some query" # Text search
|
||||
assert mock_search.call_args_list[1][1]["search_type"] == "text"
|
||||
|
||||
# Verify result contains helpful information
|
||||
assert "Note Not Found" in result
|
||||
|
||||
@@ -42,6 +42,33 @@ async def test_read_file_text_file(app, synced_files):
|
||||
assert response["encoding"] == "utf-8"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_content_file_path(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 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 read_content("test/Text Resource.md")
|
||||
|
||||
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_file_image_file(app, synced_files):
|
||||
"""Test reading an image file.
|
||||
|
||||
@@ -4,12 +4,11 @@ import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from basic_memory.mcp.tools import write_note
|
||||
from basic_memory.mcp.tools.search import search
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic(client):
|
||||
async def test_search_text(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
@@ -21,8 +20,67 @@ async def test_search_basic(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
response = await search(query)
|
||||
response = await search_notes(query="searchable")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_title(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="Search Note", search_type="title")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="test/test-search-note", search_type="permalink")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_match(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="test/test-search-*", search_type="permalink")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
@@ -42,8 +100,7 @@ async def test_search_pagination(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
response = await search(query, page=1, page_size=1)
|
||||
response = await search_notes(query="searchable", page=1, page_size=1)
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) == 1
|
||||
@@ -61,8 +118,24 @@ async def test_search_with_type_filter(client):
|
||||
)
|
||||
|
||||
# Search with type filter
|
||||
query = SearchQuery(text="type", types=[SearchItemType.ENTITY])
|
||||
response = await search(query)
|
||||
response = await search_notes(query="type", types=["note"])
|
||||
|
||||
# Verify all results are entities
|
||||
assert all(r.type == "entity" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_entity_type_filter(client):
|
||||
"""Test search with entity type filter."""
|
||||
# Create test content
|
||||
await write_note(
|
||||
title="Entity Type Test",
|
||||
folder="test",
|
||||
content="# Test\nFiltered by type",
|
||||
)
|
||||
|
||||
# Search with entity type filter
|
||||
response = await search_notes(query="type", entity_types=["entity"])
|
||||
|
||||
# Verify all results are entities
|
||||
assert all(r.type == "entity" for r in response.results)
|
||||
@@ -80,8 +153,7 @@ async def test_search_with_date_filter(client):
|
||||
|
||||
# Search with date filter
|
||||
one_hour_ago = datetime.now() - timedelta(hours=1)
|
||||
query = SearchQuery(text="recent", after_date=one_hour_ago)
|
||||
response = await search(query)
|
||||
response = await search_notes(query="recent", after_date=one_hour_ago.isoformat())
|
||||
|
||||
# Verify we get results within timeframe
|
||||
assert len(response.results) > 0
|
||||
|
||||
@@ -26,8 +26,10 @@ async def test_write_note(app):
|
||||
assert result
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
# Created note
|
||||
file_path: test/Test Note.md
|
||||
permalink: test/test-note
|
||||
checksum: 159f2168
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
@@ -63,8 +65,10 @@ async def test_write_note_no_tags(app):
|
||||
assert result
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Simple Note.md (9a1ff079)
|
||||
# Created note
|
||||
file_path: test/Simple Note.md
|
||||
permalink: test/simple-note
|
||||
checksum: 9a1ff079
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
@@ -104,8 +108,10 @@ async def test_write_note_update_existing(app):
|
||||
assert result # Got a valid permalink
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
# Created note
|
||||
file_path: test/Test Note.md
|
||||
permalink: test/test-note
|
||||
checksum: 159f2168
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
@@ -121,8 +127,10 @@ async def test_write_note_update_existing(app):
|
||||
)
|
||||
assert (
|
||||
dedent("""
|
||||
# Updated test/Test Note.md (a8eb4d44)
|
||||
# Updated note
|
||||
file_path: test/Test Note.md
|
||||
permalink: test/test-note
|
||||
checksum: a8eb4d44
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
@@ -235,8 +243,10 @@ async def test_write_note_verbose(app):
|
||||
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (06873a7a)
|
||||
# Created note
|
||||
file_path: test/Test Note.md
|
||||
permalink: test/test-note
|
||||
checksum: 06873a7a
|
||||
|
||||
## Observations
|
||||
- note: 1
|
||||
@@ -302,7 +312,7 @@ async def test_write_note_preserves_custom_metadata(app, test_config):
|
||||
)
|
||||
|
||||
# Verify the update was successful
|
||||
assert "Updated test/Custom Metadata Note.md" in result
|
||||
assert ("Updated note\nfile_path: test/Custom Metadata Note.md") in result
|
||||
|
||||
# Read the note back and check if custom frontmatter is preserved
|
||||
content = await read_note("test/custom-metadata-note")
|
||||
@@ -320,3 +330,50 @@ async def test_write_note_preserves_custom_metadata(app, test_config):
|
||||
# And tags should be updated
|
||||
assert "'#test'" in content
|
||||
assert "'#updated'" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_preserves_content_frontmatter(app):
|
||||
"""Test creating a new note."""
|
||||
await write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content=dedent(
|
||||
"""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
version: 1.0
|
||||
author: name
|
||||
---
|
||||
# Test
|
||||
|
||||
This is a test note
|
||||
"""
|
||||
),
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await read_note("test/test-note")
|
||||
assert (
|
||||
dedent(
|
||||
"""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
version: 1.0
|
||||
author: name
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
|
||||
This is a test note
|
||||
"""
|
||||
).strip()
|
||||
in content
|
||||
)
|
||||
@@ -32,12 +32,12 @@ def test_search_filters():
|
||||
"""Test search result filtering."""
|
||||
query = SearchQuery(
|
||||
text="search",
|
||||
types=[SearchItemType.ENTITY],
|
||||
entity_types=["component"],
|
||||
entity_types=[SearchItemType.ENTITY],
|
||||
types=["component"],
|
||||
after_date=datetime(2024, 1, 1),
|
||||
)
|
||||
assert query.types == [SearchItemType.ENTITY]
|
||||
assert query.entity_types == ["component"]
|
||||
assert query.entity_types == [SearchItemType.ENTITY]
|
||||
assert query.types == ["component"]
|
||||
assert query.after_date == "2024-01-01T00:00:00"
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ from textwrap import dedent
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.services import FileService
|
||||
from basic_memory.services.entity_service import EntityService
|
||||
from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
|
||||
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -261,7 +263,7 @@ async def test_get_entities_by_permalinks(entity_service: EntityService):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oget_entities_empty_input(entity_service: EntityService):
|
||||
async def test_get_entities_empty_input(entity_service: EntityService):
|
||||
"""Test opening nodes with empty path ID list."""
|
||||
found = await entity_service.get_entities_by_permalinks([])
|
||||
assert len(found) == 0
|
||||
@@ -557,3 +559,33 @@ async def test_update_with_content(entity_service: EntityService, file_service:
|
||||
|
||||
# assert content is in file
|
||||
assert update_content.strip() == file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_no_frontmatter(
|
||||
test_config: ProjectConfig,
|
||||
entity_parser: EntityParser,
|
||||
entity_service: EntityService,
|
||||
file_service: FileService,
|
||||
):
|
||||
# contains no frontmatter
|
||||
content = "# Git Workflow Guide"
|
||||
file_path = Path("test/Git Workflow Guide.md")
|
||||
full_path = test_config.home / file_path
|
||||
|
||||
await file_service.write_file(Path(full_path), content)
|
||||
|
||||
entity_markdown = await entity_parser.parse_file(full_path)
|
||||
created = await entity_service.create_entity_from_markdown(file_path, entity_markdown)
|
||||
file_content, _ = await file_service.read_file(created.file_path)
|
||||
|
||||
assert str(file_path) == str(created.file_path)
|
||||
assert created.title == "Git Workflow Guide"
|
||||
assert created.entity_type == "note"
|
||||
assert created.permalink is None
|
||||
|
||||
# assert file
|
||||
expected = dedent("""
|
||||
# Git Workflow Guide
|
||||
""").strip()
|
||||
assert expected == file_content
|
||||
|
||||
@@ -75,7 +75,7 @@ async def test_search_permalink_wildcard2(search_service, test_graph):
|
||||
async def test_search_text(search_service, test_graph):
|
||||
"""Full-text search"""
|
||||
results = await search_service.search(
|
||||
SearchQuery(text="Root Entity", types=[SearchItemType.ENTITY])
|
||||
SearchQuery(text="Root Entity", entity_types=[SearchItemType.ENTITY])
|
||||
)
|
||||
assert len(results) >= 1
|
||||
assert results[0].permalink == "test/root"
|
||||
@@ -84,7 +84,9 @@ async def test_search_text(search_service, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_title(search_service, test_graph):
|
||||
"""Title only search"""
|
||||
results = await search_service.search(SearchQuery(title="Root", types=[SearchItemType.ENTITY]))
|
||||
results = await search_service.search(
|
||||
SearchQuery(title="Root", entity_types=[SearchItemType.ENTITY])
|
||||
)
|
||||
assert len(results) >= 1
|
||||
assert results[0].permalink == "test/root"
|
||||
|
||||
@@ -140,7 +142,7 @@ async def test_filters(search_service, test_graph):
|
||||
"""Test search filters."""
|
||||
# Combined filters
|
||||
results = await search_service.search(
|
||||
SearchQuery(text="Deep", types=[SearchItemType.ENTITY], entity_types=["deep"])
|
||||
SearchQuery(text="Deep", entity_types=[SearchItemType.ENTITY], types=["deep"])
|
||||
)
|
||||
assert len(results) == 1
|
||||
for r in results:
|
||||
@@ -179,13 +181,18 @@ async def test_search_type(search_service, test_graph):
|
||||
"""Test search filters."""
|
||||
|
||||
# Should find only type
|
||||
results = await search_service.search(SearchQuery(types=[SearchItemType.ENTITY]))
|
||||
results = await search_service.search(SearchQuery(types=["test"]))
|
||||
assert len(results) > 0
|
||||
for r in results:
|
||||
assert r.type == SearchItemType.ENTITY
|
||||
|
||||
# Should find only types passed in
|
||||
results = await search_service.search(SearchQuery(types=[SearchItemType.ENTITY]))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_entity_type(search_service, test_graph):
|
||||
"""Test search filters."""
|
||||
|
||||
# Should find only type
|
||||
results = await search_service.search(SearchQuery(entity_types=[SearchItemType.ENTITY]))
|
||||
assert len(results) > 0
|
||||
for r in results:
|
||||
assert r.type == SearchItemType.ENTITY
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.models import Entity
|
||||
@@ -763,8 +765,28 @@ async def test_sync_permalink_resolved_on_update(
|
||||
|
||||
one_file = project_dir / "one.md"
|
||||
two_file = project_dir / "two.md"
|
||||
await create_test_file(one_file)
|
||||
await create_test_file(two_file)
|
||||
await create_test_file(
|
||||
one_file,
|
||||
content=dedent(
|
||||
"""
|
||||
---
|
||||
permalink: one
|
||||
---
|
||||
test content
|
||||
"""
|
||||
),
|
||||
)
|
||||
await create_test_file(
|
||||
two_file,
|
||||
content=dedent(
|
||||
"""
|
||||
---
|
||||
permalink: two
|
||||
---
|
||||
test content
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# Run sync
|
||||
await sync_service.sync(test_config.home, show_progress=False)
|
||||
@@ -819,7 +841,7 @@ test content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_duplicate_observations(
|
||||
async def test_sync_permalink_not_created_if_no_frontmatter(
|
||||
sync_service: SyncService,
|
||||
test_config: ProjectConfig,
|
||||
file_service: FileService,
|
||||
@@ -827,40 +849,73 @@ async def test_sync_duplicate_observations(
|
||||
"""Test that sync resolves permalink conflicts on update."""
|
||||
project_dir = test_config.home
|
||||
|
||||
content = """
|
||||
---
|
||||
title: a note
|
||||
type: note
|
||||
tags: []
|
||||
---
|
||||
|
||||
test content
|
||||
|
||||
- [note] one observation
|
||||
"""
|
||||
|
||||
note_file = project_dir / "note.md"
|
||||
await create_test_file(note_file, content)
|
||||
file = project_dir / "one.md"
|
||||
await create_test_file(file)
|
||||
|
||||
# Run sync
|
||||
await sync_service.sync(test_config.home, show_progress=False)
|
||||
|
||||
# Check permalinks
|
||||
file_one_content, _ = await file_service.read_file(note_file)
|
||||
assert (
|
||||
"""---
|
||||
title: a note
|
||||
type: note
|
||||
tags: []
|
||||
permalink: note
|
||||
---
|
||||
# Check permalink not created
|
||||
file_content, _ = await file_service.read_file(file)
|
||||
assert "permalink:" not in file_content
|
||||
|
||||
test content
|
||||
|
||||
- [note] one observation
|
||||
""".strip()
|
||||
== file_one_content
|
||||
@pytest_asyncio.fixture
|
||||
def test_config_update_permamlinks_on_move(tmp_path) -> ProjectConfig:
|
||||
"""Test configuration using in-memory DB."""
|
||||
config = ProjectConfig(
|
||||
project="test-project",
|
||||
update_permalinks_on_move=True,
|
||||
)
|
||||
config.home = tmp_path
|
||||
|
||||
(tmp_path / config.home.name).mkdir(parents=True, exist_ok=True)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_permalink_updated_on_move(
|
||||
test_config_update_permamlinks_on_move: ProjectConfig,
|
||||
sync_service: SyncService,
|
||||
file_service: FileService,
|
||||
):
|
||||
"""Test that we update a permalink on a file move if set in config ."""
|
||||
test_config = test_config_update_permamlinks_on_move
|
||||
project_dir = test_config.home
|
||||
sync_service.config = test_config
|
||||
|
||||
# Create initial file
|
||||
content = dedent(
|
||||
"""
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Move
|
||||
Content for move test
|
||||
"""
|
||||
)
|
||||
|
||||
old_path = project_dir / "old" / "test_move.md"
|
||||
old_path.parent.mkdir(parents=True)
|
||||
await create_test_file(old_path, content)
|
||||
|
||||
# Initial sync
|
||||
await sync_service.sync(test_config.home, show_progress=False)
|
||||
|
||||
# verify permalink
|
||||
old_content, _ = await file_service.read_file(old_path)
|
||||
assert "permalink: old/test-move" in old_content
|
||||
|
||||
# Move the file
|
||||
new_path = project_dir / "new" / "moved_file.md"
|
||||
new_path.parent.mkdir(parents=True)
|
||||
old_path.rename(new_path)
|
||||
|
||||
# Sync again
|
||||
await sync_service.sync(test_config.home, show_progress=False)
|
||||
|
||||
file_content, _ = await file_service.read_file(new_path)
|
||||
assert "permalink: new/moved-file" in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Test for issue #72 - notes with wikilinks staying in modified status."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.sync.sync_service import SyncService
|
||||
|
||||
|
||||
async def create_test_file(path: Path, content: str) -> 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_wikilink_modified_status_issue(sync_service: SyncService, test_config):
|
||||
"""Test that files with wikilinks don't remain in modified status after sync."""
|
||||
project_dir = test_config.home
|
||||
|
||||
# Create a file with a wikilink
|
||||
content = """---
|
||||
title: Test Wikilink
|
||||
type: note
|
||||
---
|
||||
# Test File
|
||||
|
||||
This file contains a wikilink to [[another-file]].
|
||||
"""
|
||||
test_file_path = project_dir / "test_wikilink.md"
|
||||
await create_test_file(test_file_path, content)
|
||||
|
||||
# Initial sync
|
||||
report1 = await sync_service.sync(test_config.home, show_progress=False)
|
||||
assert "test_wikilink.md" in report1.new
|
||||
assert "test_wikilink.md" not in report1.modified
|
||||
|
||||
# Sync again without changing the file - should not be modified
|
||||
report2 = await sync_service.sync(test_config.home, show_progress=False)
|
||||
assert "test_wikilink.md" not in report2.new
|
||||
assert "test_wikilink.md" not in report2.modified
|
||||
|
||||
# Create the target file
|
||||
target_content = """---
|
||||
title: Another File
|
||||
type: note
|
||||
---
|
||||
# Another File
|
||||
|
||||
This is the target file.
|
||||
"""
|
||||
target_file_path = project_dir / "another_file.md"
|
||||
await create_test_file(target_file_path, target_content)
|
||||
|
||||
# Sync again after adding target file
|
||||
report3 = await sync_service.sync(test_config.home, show_progress=False)
|
||||
assert "another_file.md" in report3.new
|
||||
assert "test_wikilink.md" not in report3.modified
|
||||
|
||||
# Sync one more time - both files should now be stable
|
||||
report4 = await sync_service.sync(test_config.home, show_progress=False)
|
||||
assert "test_wikilink.md" not in report4.modified
|
||||
assert "another_file.md" not in report4.modified
|
||||
@@ -96,7 +96,7 @@ Test content
|
||||
# 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"
|
||||
assert entity.title == "new_note"
|
||||
|
||||
# Check event was recorded
|
||||
events = [e for e in watch_service.state.recent_events if e.action == "new"]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for parse_tags utility function."""
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.utils import parse_tags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_tags,expected",
|
||||
[
|
||||
# None input
|
||||
(None, []),
|
||||
# List inputs
|
||||
([], []),
|
||||
(["tag1", "tag2"], ["tag1", "tag2"]),
|
||||
(["tag1", "", "tag2"], ["tag1", "tag2"]), # Empty tags are filtered
|
||||
([" tag1 ", " tag2 "], ["tag1", "tag2"]), # Whitespace is stripped
|
||||
# String inputs
|
||||
("", []),
|
||||
("tag1", ["tag1"]),
|
||||
("tag1,tag2", ["tag1", "tag2"]),
|
||||
("tag1, tag2", ["tag1", "tag2"]), # Whitespace after comma is stripped
|
||||
("tag1,,tag2", ["tag1", "tag2"]), # Empty tags are filtered
|
||||
# Tags with leading '#' characters - these should be stripped
|
||||
(["#tag1", "##tag2"], ["tag1", "tag2"]),
|
||||
("#tag1,##tag2", ["tag1", "tag2"]),
|
||||
(["tag1", "#tag2", "##tag3"], ["tag1", "tag2", "tag3"]),
|
||||
# Mixed whitespace and '#' characters
|
||||
([" #tag1 ", " ##tag2 "], ["tag1", "tag2"]),
|
||||
(" #tag1 , ##tag2 ", ["tag1", "tag2"]),
|
||||
],
|
||||
)
|
||||
def test_parse_tags(input_tags: Union[List[str], str, None], expected: List[str]) -> None:
|
||||
"""Test tag parsing with various input formats."""
|
||||
result = parse_tags(input_tags)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_parse_tags_special_case() -> None:
|
||||
"""Test parsing from non-string, non-list types."""
|
||||
|
||||
# Test with custom object that has __str__ method
|
||||
class TagObject:
|
||||
def __str__(self) -> str:
|
||||
return "tag1,tag2"
|
||||
|
||||
result = parse_tags(TagObject()) # pyright: ignore [reportArgumentType]
|
||||
assert result == ["tag1", "tag2"]
|
||||
@@ -1,7 +1,8 @@
|
||||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.12.1"
|
||||
resolution-markers = [
|
||||
"(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or (platform_machine == 'aarch64' and sys_platform != 'linux') or (platform_machine == 'armv7l' and sys_platform != 'linux') or (platform_machine == 'i686' and sys_platform != 'linux') or (platform_machine == 'ppc64le' and sys_platform != 'linux') or (platform_machine == 's390x' and sys_platform != 'linux') or (platform_machine == 'x86_64' and sys_platform != 'linux')",
|
||||
"(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'",
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 'i686' and sys_platform == 'linux'",
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
@@ -70,7 +71,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basic-memory"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -170,7 +171,7 @@ name = "cffi"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or (platform_machine == 'aarch64' and sys_platform != 'linux') or (platform_machine == 'armv7l' and sys_platform != 'linux') or (platform_machine == 'i686' and sys_platform != 'linux') or (platform_machine == 'ppc64le' and sys_platform != 'linux') or (platform_machine == 's390x' and sys_platform != 'linux') or (platform_machine == 'x86_64' and sys_platform != 'linux')" },
|
||||
{ name = "pycparser", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
|
||||
wheels = [
|
||||
@@ -185,7 +186,7 @@ name = "click"
|
||||
version = "8.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||
wheels = [
|
||||
@@ -309,8 +310,8 @@ name = "dmgbuild"
|
||||
version = "1.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ds-store", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or (platform_machine == 'aarch64' and sys_platform != 'linux') or (platform_machine == 'armv7l' and sys_platform != 'linux') or (platform_machine == 'i686' and sys_platform != 'linux') or (platform_machine == 'ppc64le' and sys_platform != 'linux') or (platform_machine == 's390x' and sys_platform != 'linux') or (platform_machine == 'x86_64' and sys_platform != 'linux')" },
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or (platform_machine == 'aarch64' and sys_platform != 'linux') or (platform_machine == 'armv7l' and sys_platform != 'linux') or (platform_machine == 'i686' and sys_platform != 'linux') or (platform_machine == 'ppc64le' and sys_platform != 'linux') or (platform_machine == 's390x' and sys_platform != 'linux') or (platform_machine == 'x86_64' and sys_platform != 'linux')" },
|
||||
{ name = "ds-store", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/93/b9702c68d5dedfd6b91c76268a89091ff681b8e3b9a026e7919b6ab730a4/dmgbuild-1.6.5.tar.gz", hash = "sha256:c5cbeec574bad84a324348aa7c36d4aada04568c99fb104dec18d22ba3259f45", size = 36848 }
|
||||
wheels = [
|
||||
@@ -331,7 +332,7 @@ name = "ds-store"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or (platform_machine == 'aarch64' and sys_platform != 'linux') or (platform_machine == 'armv7l' and sys_platform != 'linux') or (platform_machine == 'i686' and sys_platform != 'linux') or (platform_machine == 'ppc64le' and sys_platform != 'linux') or (platform_machine == 's390x' and sys_platform != 'linux') or (platform_machine == 'x86_64' and sys_platform != 'linux')" },
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/36/902259bf7ddb142dd91cf7a9794aa15e1a8ab985974f90375e5d3463b441/ds_store-1.3.1.tar.gz", hash = "sha256:c27d413caf13c19acb85d75da4752673f1f38267f9eb6ba81b3b5aa99c2d207c", size = 27052 }
|
||||
wheels = [
|
||||
@@ -818,7 +819,7 @@ email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
timezone = [
|
||||
{ name = "tzdata", marker = "platform_system == 'Windows'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1308,7 +1309,7 @@ name = "tzlocal"
|
||||
version = "5.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "platform_system == 'Windows'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 }
|
||||
wheels = [
|
||||
|
||||
Reference in New Issue
Block a user