Compare commits

...

27 Commits

Author SHA1 Message Date
semantic-release 7ce7a29f35 chore(release): 0.12.0 [skip ci] 2025-04-06 23:44:17 +00:00
Paul Hernandez 3f4d9e4d87 fix: write_note preserves frontmatter fields in content (#84) 2025-04-06 18:39:51 -05:00
Paul Hernandez 00c8633cfc feat: add watch to mcp process (#83) 2025-04-06 18:33:10 -05:00
Paul Hernandez 617e60bda4 feat: permalink enhancements (#82)
- Avoiding "useless permalink values" for files without metadata
- Enable permalinks to be updated on move via config setting
2025-04-06 14:54:59 -05:00
github-actions[bot] 6c19c9edf5 fix: [BUG] # character accumulation in markdown frontmatter tags prop (#79) 2025-04-05 23:07:53 -05:00
github-actions[bot] 9bff1f732e fix: [BUG] write_note Tool Fails to Update Existing Files in Some Situations. (#80) 2025-04-05 22:31:40 -05:00
Paul Hernandez 248214cb11 fix: set default mcp log level to ERROR (#81) 2025-04-05 22:27:29 -05:00
Jason Noble 40ea28b0bf docs: Updated basicmachines.co links to be https (#69) 2025-04-05 22:22:37 -05:00
Marc Baiza 43cbb7b38c docs: Add VS Code instructions to README (#76) 2025-04-05 22:18:56 -05:00
github-actions[bot] 7930ddb291 fix: [BUG] Some notes never exit "modified" status (#77) 2025-04-05 22:03:38 -05:00
phernandez 1844e58210 Add pull_request_target trigger to test workflow with explanatory comment 2025-04-05 20:30:05 -05:00
phernandez 7fbb5ebfc5 Update claude-code-github-action to v0.11.0 with backtick handling fix 2025-04-05 20:04:18 -05:00
phernandez 0e5a465b10 add claude-output to .gitignore
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-05 19:45:07 -05:00
github-actions[bot] 9d581cee13 fix: [BUG] Cursor has errors calling search tool (#78) 2025-04-05 19:12:23 -05:00
phernandez f58852954b Update to claude-code-github-action v0.10.0 to check comment author for org membership 2025-04-05 12:54:33 -05:00
phernandez edd426b99f Update to claude-code-github-action v0.9.0 with organization membership check fix 2025-04-05 12:42:35 -05:00
phernandez 6b4a421315 Update to claude-code-github-action v0.8.0 with PERSONAL_ACCESS_TOKEN support 2025-04-05 12:13:05 -05:00
phernandez 4632405446 Fix claude-code-actions.yml to use v0.7.0 of basicmachines-co action 2025-04-05 12:01:04 -05:00
phernandez 3a6d5ff58d update claude code actions
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-04 23:31:08 -05:00
phernandez 03de85ec32 add claude code github action workflow
Signed-off-by: phernandez <paul@basicmachines.co>
2025-04-04 21:20:40 -05:00
phernandez 084c91ecce update basic-memory dep version
Signed-off-by: phernandez <paul@basicmachines.co>
2025-03-30 17:46:44 -05:00
semantic-release 3d45227b72 chore(release): 0.11.0 [skip ci] 2025-03-29 00:19:46 +00:00
Paul Hernandez 069c0a21c6 feat: add bm command alias for basic-memory (#67)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-03-28 19:12:25 -05:00
Paul Hernandez b27827671d feat: rename search tool to search_notes (#66)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-03-28 19:11:35 -05:00
Paul Hernandez 0743ade5fc fix: just delete db for reset db instead of using migrations. (#65)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-03-28 19:11:11 -05:00
Paul Hernandez f1c95709cb fix: make logs for each process - mcp, sync, cli (#64)
Signed-off-by: phernandez <paul@basicmachines.co>
2025-03-28 19:10:43 -05:00
Matias Forbord 3c68b7d5dd docs: Update broken "Multiple Projects" link in README.md (#55) 2025-03-26 14:31:51 -05:00
60 changed files with 1183 additions and 308 deletions
+16
View File
@@ -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 }}
+6
View File
@@ -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
View File
@@ -47,4 +47,8 @@ ENV/
# obsidian docs:
/docs/.obsidian/
/examples/.obsidian/
/examples/.basic-memory/
/examples/.basic-memory/
# claude action
claude-output
+87
View File
@@ -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
+2 -2
View File
@@ -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
+43 -5
View File
@@ -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...
[![Install with UV in VS Code](https://img.shields.io/badge/VS_Code-UV-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install with UV in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-UV-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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
View File
+13
View File
@@ -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...
+35
View File
@@ -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---
+49
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.10.1"
__version__ = "0.12.0"
+42 -3
View File
@@ -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()
-7
View File
@@ -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")
+15 -2
View File
@@ -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
+8 -1
View File
@@ -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()
+1
View File
@@ -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,
+30 -17
View File
@@ -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
+16 -7
View File
@@ -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
View File
@@ -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()
+3 -1
View File
@@ -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:
+3
View File
@@ -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
+17 -8
View File
@@ -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,
+21 -13
View File
@@ -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]")`
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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",
]
+3 -4
View File
@@ -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:
+64 -28
View File
@@ -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())
+3 -1
View File
@@ -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:
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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
+8 -1
View File
@@ -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:
+3 -23
View File
@@ -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(
+59 -22
View File
@@ -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(
+10 -2
View File
@@ -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
+5 -5
View File
@@ -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
+3 -3
View File
@@ -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
+8 -8
View File
@@ -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(),
},
)
+2 -2
View File
@@ -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
View File
@@ -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,
+31
View File
@@ -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."""
+10 -5
View File
@@ -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
+27
View File
@@ -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.
+83 -11
View 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
)
+4 -4
View File
@@ -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"
+34 -2
View File
@@ -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
+13 -6
View File
@@ -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
+85 -30
View File
@@ -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
+62
View File
@@ -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
+1 -1
View File
@@ -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"]
+50
View File
@@ -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"]
Generated
+10 -9
View File
@@ -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 = [