mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8cefcd45 | |||
| 57984aa912 | |||
| f5a7541da1 | |||
| bc9ca0744f | |||
| 2c8ed1737d | |||
| 02f8e86692 | |||
| 0123544556 | |||
| 00d23a5ee1 | |||
| 812136c8c2 | |||
| 3e8e3e8961 | |||
| 8544bb7966 | |||
| 66b57e682f |
@@ -1,8 +1,49 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v0.7.0 (2025-02-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add logfire instrumentation to tools
|
||||
([`3e8e3e8`](https://github.com/basicmachines-co/basic-memory/commit/3e8e3e8961eae2e82839746e28963191b0aef0a0))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`00d23a5`](https://github.com/basicmachines-co/basic-memory/commit/00d23a5ee15ddac4ea45e702dcd02ab9f0509276))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`812136c`](https://github.com/basicmachines-co/basic-memory/commit/812136c8c22ad191d14ff32dcad91aae076d4120))
|
||||
|
||||
- Search query pagination params
|
||||
([`bc9ca07`](https://github.com/basicmachines-co/basic-memory/commit/bc9ca0744ffe4296d7d597b4dd9b7c73c2d63f3f))
|
||||
|
||||
### Chores
|
||||
|
||||
- Fix tests
|
||||
([`57984aa`](https://github.com/basicmachines-co/basic-memory/commit/57984aa912625dcde7877afb96d874c164af2896))
|
||||
|
||||
- Remove unused tests
|
||||
([`2c8ed17`](https://github.com/basicmachines-co/basic-memory/commit/2c8ed1737d6769fe1ef5c96f8a2bd75b9899316a))
|
||||
|
||||
### Features
|
||||
|
||||
- Add cli commands for mcp tools
|
||||
([`f5a7541`](https://github.com/basicmachines-co/basic-memory/commit/f5a7541da17e97403b7a702720a05710f68b223a))
|
||||
|
||||
- Add pagination to build_context and recent_activity
|
||||
([`0123544`](https://github.com/basicmachines-co/basic-memory/commit/0123544556513af943d399d70b849b142b834b15))
|
||||
|
||||
- Add pagination to read_notes
|
||||
([`02f8e86`](https://github.com/basicmachines-co/basic-memory/commit/02f8e866923d5793d2620076c709c920d99f2c4f))
|
||||
|
||||
|
||||
## v0.6.0 (2025-02-18)
|
||||
|
||||
### Chores
|
||||
|
||||
- Re-add sync status console on watch
|
||||
([`66b57e6`](https://github.com/basicmachines-co/basic-memory/commit/66b57e682f2e9c432bffd4af293b0d1db1d3469b))
|
||||
|
||||
### Features
|
||||
|
||||
- Configure logfire telemetry ([#12](https://github.com/basicmachines-co/basic-memory/pull/12),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win check
|
||||
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
@@ -40,4 +40,6 @@ installer-win:
|
||||
|
||||
|
||||
update-deps:
|
||||
uv lock f--upgrade
|
||||
uv lock f--upgrade
|
||||
|
||||
check: lint format type-check test
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
"fastapi[standard]>=0.115.8",
|
||||
"alembic>=1.14.1",
|
||||
"qasync>=0.27.1",
|
||||
"logfire[fastapi,sqlalchemy,sqlite3]>=3.6.0",
|
||||
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -94,11 +94,8 @@ async def get_entity(
|
||||
try:
|
||||
entity = await entity_service.get_by_permalink(permalink)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(f"response: get_entity with result={result}")
|
||||
return result
|
||||
except EntityNotFoundError:
|
||||
logger.error(f"Error: Entity with {permalink} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
|
||||
|
||||
|
||||
@@ -114,8 +111,6 @@ async def get_entities(
|
||||
result = EntityListResponse(
|
||||
entities=[EntityResponse.model_validate(entity) for entity in entities]
|
||||
)
|
||||
|
||||
logger.info(f"response: get_entities with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -135,7 +130,6 @@ async def delete_entity(
|
||||
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if entity is None:
|
||||
logger.info("response: delete_entity with result=DeleteEntitiesResponse(deleted=False)")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
@@ -145,7 +139,6 @@ async def delete_entity(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entity with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -166,5 +159,4 @@ async def delete_entities(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entities with result={result}")
|
||||
return result
|
||||
|
||||
@@ -24,7 +24,7 @@ from basic_memory.services.context_service import ContextResultRow
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
|
||||
# return results
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
@@ -66,7 +66,11 @@ async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
metadata = MemoryMetadata.model_validate(context["metadata"])
|
||||
# Transform to GraphContext
|
||||
return GraphContext(
|
||||
primary_results=primary_results, related_results=related_results, metadata=metadata
|
||||
primary_results=primary_results,
|
||||
related_results=related_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +81,9 @@ async def recent(
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
@@ -87,16 +93,20 @@ async def recent(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, max_results=max_results
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
@@ -109,21 +119,27 @@ async def get_memory_context(
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, max_results=max_results
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
@@ -21,16 +21,16 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> list[int]:
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return [item.id]
|
||||
return {item.id}
|
||||
case SearchItemType.OBSERVATION:
|
||||
return [item.entity_id] # pyright: ignore [reportReturnType]
|
||||
return {item.entity_id} # pyright: ignore [reportReturnType]
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = item.from_id
|
||||
to_entity = item.to_id # pyright: ignore [reportReturnType]
|
||||
return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType]
|
||||
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
@@ -44,6 +44,8 @@ async def get_resource_content(
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> FileResponse:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
@@ -52,6 +54,10 @@ async def get_resource_content(
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
results = [entity] if entity else []
|
||||
|
||||
# pagination for multiple results
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# search using the identifier as a permalink
|
||||
if not results:
|
||||
# if the identifier contains a wildcard, use GLOB search
|
||||
@@ -60,13 +66,13 @@ async def get_resource_content(
|
||||
if "*" in identifier
|
||||
else SearchQuery(permalink=identifier)
|
||||
)
|
||||
search_results = await search_service.search(query)
|
||||
search_results = await search_service.search(query, limit, offset)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the entities related to the search results
|
||||
entity_ids = [id for result in search_results for id in get_entity_ids(result)]
|
||||
results = await entity_service.get_entities_by_id(entity_ids)
|
||||
# get the deduplicated entities related to the search results
|
||||
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
|
||||
results = await entity_service.get_entities_by_id(list(entity_ids))
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
|
||||
@@ -2,27 +2,35 @@
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
|
||||
from basic_memory.deps import get_search_service
|
||||
from basic_memory.deps import SearchServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SearchResponse)
|
||||
async def search(query: SearchQuery, search_service: SearchService = Depends(get_search_service)):
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceDep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
results = await search_service.search(query)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
|
||||
return SearchResponse(results=search_results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(
|
||||
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
|
||||
):
|
||||
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
|
||||
"""Recreate and populate the search index."""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
|
||||
@@ -6,7 +6,7 @@ from basic_memory import db
|
||||
from basic_memory.config import config
|
||||
from basic_memory.utils import setup_logging
|
||||
|
||||
setup_logging(log_file=".basic-memory/basic-memory-cli.log") # pragma: no cover
|
||||
setup_logging(log_file=".basic-memory/basic-memory-cli.log", console=False) # pragma: no cover
|
||||
|
||||
asyncio.run(db.run_migrations(config))
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
@@ -13,13 +15,14 @@ def reset(
|
||||
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
|
||||
): # pragma: no cover
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated, Set, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -209,7 +210,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
def import_chatgpt(
|
||||
conversations_json: Annotated[
|
||||
Path, typer.Option(..., help="Path to ChatGPT conversations.json file")
|
||||
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
|
||||
] = Path("conversations.json"),
|
||||
folder: Annotated[
|
||||
str, typer.Option(help="The folder to place the files in.")
|
||||
@@ -225,35 +226,38 @@ def import_chatgpt(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(
|
||||
f"\nImporting chats from {conversations_json}...writing to {base_path}"
|
||||
)
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -178,34 +179,35 @@ def import_claude(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Annotated, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -160,36 +161,36 @@ def import_projects(
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -113,32 +114,33 @@ def memory_json(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Set, Dict
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -146,9 +147,10 @@ def status(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
try:
|
||||
sync_service = asyncio.run(get_file_change_scanner())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
sync_service = asyncio.run(get_file_change_scanner())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
@@ -151,7 +151,7 @@ def display_detailed_sync_results(knowledge: SyncReport):
|
||||
console.print(knowledge_tree)
|
||||
|
||||
|
||||
async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
|
||||
"""Run sync operation."""
|
||||
|
||||
sync_service = await get_sync_service()
|
||||
@@ -164,7 +164,7 @@ async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
config=config,
|
||||
)
|
||||
await watch_service.handle_changes(config.home)
|
||||
await watch_service.run() # pragma: no cover
|
||||
await watch_service.run(console_status=console_status) # pragma: no cover
|
||||
else:
|
||||
# one time sync
|
||||
knowledge_changes = await sync_service.sync(config.home)
|
||||
@@ -189,11 +189,14 @@ def sync(
|
||||
"-w",
|
||||
help="Start watching for changes after sync.",
|
||||
),
|
||||
console_status: bool = typer.Option(
|
||||
False, "--console-status", "-c", help="Show live console status"
|
||||
),
|
||||
) -> None:
|
||||
"""Sync knowledge files with the database."""
|
||||
try:
|
||||
# Run sync
|
||||
asyncio.run(run_sync(verbose=verbose, watch=watch))
|
||||
asyncio.run(run_sync(verbose=verbose, watch=watch, console_status=console_status))
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Annotated
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
from basic_memory.mcp.tools import get_entity as mcp_get_entity
|
||||
from basic_memory.mcp.tools import read_note as mcp_read_note
|
||||
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
|
||||
from basic_memory.mcp.tools import search as mcp_search
|
||||
from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import MemoryUrl
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def write_note(
|
||||
title: Annotated[str, typer.Option(help="The title of the note")],
|
||||
content: Annotated[str, typer.Option(help="The content of the note")],
|
||||
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
|
||||
tags: Annotated[
|
||||
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
|
||||
] = None,
|
||||
):
|
||||
try:
|
||||
note = asyncio.run(mcp_write_note(title, content, folder, tags))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during write_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def read_note(identifier: str, page: int = 1, page_size: int = 10):
|
||||
try:
|
||||
note = asyncio.run(mcp_read_note(identifier, page, page_size))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during read_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
):
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_build_context(
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
rprint(context.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during build_context: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def recent_activity(
|
||||
type: Annotated[Optional[List[str]], typer.Option()] = ["entity", "observation", "relation"],
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
):
|
||||
assert type is not None, "type is required"
|
||||
if any(t not in ["entity", "observation", "relation"] for t in type): # pragma: no cover
|
||||
print("type must be one of ['entity', 'observation', 'relation']")
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_recent_activity(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
rprint(context.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during build_context: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def search(
|
||||
query: str,
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
after_date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
if permalink and title: # pragma: no cover
|
||||
print("Cannot search both permalink and title")
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
search_query = SearchQuery(
|
||||
permalink_match=query if permalink else None,
|
||||
text=query if query else None,
|
||||
title=query if title else None,
|
||||
after_date=after_date,
|
||||
)
|
||||
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
|
||||
rprint(results.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during search: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def get_entity(identifier: str):
|
||||
try:
|
||||
entity = asyncio.run(mcp_get_entity(identifier=identifier))
|
||||
rprint(entity.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during get_entity: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
@@ -12,6 +12,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tools,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
DATABASE_NAME = "memory.db"
|
||||
DATA_DIR_NAME = ".basic-memory"
|
||||
|
||||
Environment = Literal["test", "dev", "prod"]
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class ProjectConfig(BaseSettings):
|
||||
|
||||
@@ -4,6 +4,7 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import logfire
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
@@ -43,7 +44,10 @@ async def get_engine_factory(
|
||||
project_config: ProjectConfigDep,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get engine and session maker."""
|
||||
return await db.get_or_create_db(project_config.database_path)
|
||||
engine, session_maker = await db.get_or_create_db(project_config.database_path)
|
||||
if project_config.env != "test":
|
||||
logfire.instrument_sqlalchemy(engine=engine)
|
||||
return engine, session_maker
|
||||
|
||||
|
||||
EngineFactoryDep = Annotated[
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Knowledge graph management tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post
|
||||
from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.schemas.request import (
|
||||
GetEntitiesRequest,
|
||||
)
|
||||
@@ -16,15 +18,17 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Get complete information about a specific entity including observations and relations",
|
||||
)
|
||||
async def get_entity(permalink: Permalink) -> EntityResponse:
|
||||
async def get_entity(identifier: str) -> EntityResponse:
|
||||
"""Get a specific entity info by its permalink.
|
||||
|
||||
Args:
|
||||
permalink: Path identifier for the entity
|
||||
identifier: Path identifier for the entity
|
||||
"""
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
with logfire.span("Getting entity", permalink=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
permalink = memory_url_path(identifier)
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -39,11 +43,16 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
Returns:
|
||||
EntityListResponse containing complete details for each requested entity
|
||||
"""
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client, url, params=[("permalink", permalink) for permalink in request.permalinks]
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
with logfire.span("Getting multiple entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client,
|
||||
url,
|
||||
params=[
|
||||
("permalink", memory_url_path(identifier)) for identifier in request.permalinks
|
||||
],
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -51,6 +60,9 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
)
|
||||
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
|
||||
"""Delete entities from the knowledge graph."""
|
||||
url = "/knowledge/entities/delete"
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
with logfire.span("Deleting entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities/delete"
|
||||
|
||||
request.permalinks = [memory_url_path(permlink) for permlink in request.permalinks]
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Optional, Literal, List
|
||||
|
||||
from loguru import logger
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -32,7 +33,9 @@ async def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -44,7 +47,9 @@ async def build_context(
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (1-3 recommended for performance)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -65,14 +70,21 @@ async def build_context(
|
||||
# Research the history of a feature
|
||||
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -91,7 +103,9 @@ async def recent_activity(
|
||||
type: List[Literal["entity", "observation", "relation"]] = [],
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
@@ -106,7 +120,9 @@ async def recent_activity(
|
||||
- Relative: "2 days ago", "last week", "yesterday"
|
||||
- Points in time: "2024-01-01", "January 1st"
|
||||
- Standard format: "7d", "24h"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -132,20 +148,23 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
|
||||
)
|
||||
params = {
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"max_results": max_results,
|
||||
}
|
||||
if type:
|
||||
params["type"] = type
|
||||
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
|
||||
)
|
||||
params = {
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
}
|
||||
if type:
|
||||
params["type"] = type
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -7,6 +7,7 @@ while leveraging the underlying knowledge graph structure.
|
||||
from typing import Optional, List
|
||||
|
||||
from loguru import logger
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
@@ -60,75 +61,62 @@ async def write_note(
|
||||
- Observation counts by category
|
||||
- Relation counts (resolved/unresolved)
|
||||
- Tags if present
|
||||
|
||||
Examples:
|
||||
write_note(
|
||||
title="Search Implementation",
|
||||
content="# Search Component\\n\\n"
|
||||
"Implementation of the search feature, building on [[Core Search]].\\n\\n"
|
||||
"## Observations\\n"
|
||||
"- [tech] Using FTS5 for full-text search #implementation\\n"
|
||||
"- [design] Need pagination support #todo\\n\\n"
|
||||
"## Relations\\n"
|
||||
"- implements [[Search Spec]]\\n"
|
||||
"- depends_on [[Database Schema]]",
|
||||
folder="docs/components"
|
||||
)
|
||||
"""
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
assert result.checksum is not None
|
||||
summary = [
|
||||
f"# {action} {result.file_path} ({result.checksum[:8]})",
|
||||
f"permalink: {result.permalink}",
|
||||
]
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
assert result.checksum is not None
|
||||
summary = [
|
||||
f"# {action} {result.file_path} ({result.checksum[:8]})",
|
||||
f"permalink: {result.permalink}",
|
||||
]
|
||||
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
|
||||
return "\n".join(summary)
|
||||
return "\n".join(summary)
|
||||
|
||||
|
||||
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
|
||||
async def read_note(identifier: str) -> str:
|
||||
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
"""Get note content in unified diff format.
|
||||
|
||||
The content is returned in a unified diff inspired format:
|
||||
@@ -146,6 +134,8 @@ async def read_note(identifier: str) -> str:
|
||||
- Note permalink ("docs/example")
|
||||
- Relation path ("docs/example/depends-on/other-doc")
|
||||
- Pattern match ("docs/*-architecture")
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
Document content in unified diff format. For single documents, returns
|
||||
@@ -180,10 +170,13 @@ async def read_note(identifier: str) -> str:
|
||||
- Last modified timestamp
|
||||
- Content checksum
|
||||
"""
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
response = await call_get(client, f"/resource/{url}")
|
||||
return response.text
|
||||
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
response = await call_get(
|
||||
client, f"/resource/{url}", params={"page": page, "page_size": page_size}
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
@@ -203,6 +196,7 @@ async def delete_note(identifier: str) -> bool:
|
||||
# Delete by permalink
|
||||
delete_note("notes/project-planning")
|
||||
"""
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -11,7 +12,7 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Search across all content in basic-memory, including documents and entities",
|
||||
)
|
||||
async def search(query: SearchQuery) -> SearchResponse:
|
||||
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
|
||||
"""Search across all content in basic-memory.
|
||||
|
||||
Args:
|
||||
@@ -20,10 +21,18 @@ async def search(query: SearchQuery) -> SearchResponse:
|
||||
- types: Optional list of content types to search ("document" or "entity")
|
||||
- entity_types: Optional list of entity types to filter by
|
||||
- after_date: Optional date filter for recent content
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
SearchResponse with search results and metadata
|
||||
"""
|
||||
logger.info(f"Searching for {query.text}")
|
||||
response = await call_post(client, "/search/", json=query.model_dump())
|
||||
return SearchResponse.model_validate(response.json())
|
||||
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Searching for {query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
"/search/",
|
||||
json=query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
@@ -114,6 +114,7 @@ class SearchRepository:
|
||||
after_date: Optional[datetime] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
@@ -169,6 +170,7 @@ class SearchRepository:
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
@@ -194,6 +196,7 @@ class SearchRepository:
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.debug(f"Search {sql} params: {params}")
|
||||
|
||||
@@ -111,3 +111,6 @@ class GraphContext(BaseModel):
|
||||
|
||||
# Context metadata
|
||||
metadata: MemoryMetadata
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 1
|
||||
|
||||
@@ -51,7 +51,7 @@ class GetEntitiesRequest(BaseModel):
|
||||
discovered through search.
|
||||
"""
|
||||
|
||||
permalinks: Annotated[List[Permalink], MinLen(1)]
|
||||
permalinks: Annotated[List[Permalink], MinLen(1), MaxLen(10)]
|
||||
|
||||
|
||||
class CreateRelationsRequest(BaseModel):
|
||||
|
||||
@@ -102,6 +102,8 @@ class SearchResponse(BaseModel):
|
||||
"""Wrapper for search results."""
|
||||
|
||||
results: List[SearchResult]
|
||||
current_page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Schema for future advanced search endpoint
|
||||
|
||||
@@ -54,11 +54,13 @@ class ContextService:
|
||||
types: Optional[List[SearchItemType]] = None,
|
||||
depth: int = 1,
|
||||
since: Optional[datetime] = None,
|
||||
max_results: int = 10,
|
||||
limit=10,
|
||||
offset=0,
|
||||
max_related: int = 10,
|
||||
):
|
||||
"""Build rich context from a memory:// URI."""
|
||||
logger.debug(
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
|
||||
)
|
||||
|
||||
if memory_url:
|
||||
@@ -66,15 +68,21 @@ class ContextService:
|
||||
# Pattern matching - use search
|
||||
if "*" in path:
|
||||
logger.debug(f"Pattern search for '{path}'")
|
||||
primary = await self.search_repository.search(permalink_match=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink_match=path, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Direct lookup for exact path
|
||||
else:
|
||||
logger.debug(f"Direct lookup for '{path}'")
|
||||
primary = await self.search_repository.search(permalink=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink=path, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Build context for '{types}'")
|
||||
primary = await self.search_repository.search(types=types, after_date=since)
|
||||
primary = await self.search_repository.search(
|
||||
types=types, after_date=since, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
|
||||
@@ -83,7 +91,7 @@ class ContextService:
|
||||
|
||||
# Find related content
|
||||
related = await self.find_related(
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_results
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_related
|
||||
)
|
||||
logger.debug(f"Found {len(related)} related results")
|
||||
for r in related:
|
||||
|
||||
@@ -51,7 +51,7 @@ class SearchService:
|
||||
|
||||
logger.info("Reindex complete")
|
||||
|
||||
async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
|
||||
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Supports three modes:
|
||||
@@ -84,6 +84,8 @@ class SearchService:
|
||||
types=query.types,
|
||||
entity_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -61,105 +62,113 @@ class SyncService:
|
||||
|
||||
async def sync(self, directory: Path) -> SyncReport:
|
||||
"""Sync knowledge files with database."""
|
||||
changes = await self.scanner.find_knowledge_changes(directory)
|
||||
logger.info(f"Found {changes.total_changes} knowledge changes")
|
||||
|
||||
# Handle moves first
|
||||
for old_path, new_path in changes.moves.items():
|
||||
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
||||
)
|
||||
# update search index
|
||||
if updated:
|
||||
await self.search_service.index_entity(updated)
|
||||
with logfire.span("sync", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
|
||||
changes = await self.scanner.find_knowledge_changes(directory)
|
||||
logger.info(f"Found {changes.total_changes} knowledge changes")
|
||||
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
for path in changes.deleted:
|
||||
await self.handle_entity_deletion(path)
|
||||
# Handle moves first
|
||||
for old_path, new_path in changes.moves.items():
|
||||
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
||||
)
|
||||
# update search index
|
||||
if updated:
|
||||
await self.search_service.index_entity(updated)
|
||||
|
||||
# Parse files that need updating
|
||||
parsed_entities: Dict[str, EntityMarkdown] = {}
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
for path in changes.deleted:
|
||||
await self.handle_entity_deletion(path)
|
||||
|
||||
for path in [*changes.new, *changes.modified]:
|
||||
entity_markdown = await self.entity_parser.parse_file(directory / path)
|
||||
parsed_entities[path] = entity_markdown
|
||||
# Parse files that need updating
|
||||
parsed_entities: Dict[str, EntityMarkdown] = {}
|
||||
|
||||
# First pass: Create/update entities
|
||||
# entities will have a null checksum to indicate they are not complete
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
# Get unique permalink and update markdown if needed
|
||||
permalink = await self.entity_service.resolve_permalink(
|
||||
Path(path), markdown=entity_markdown
|
||||
)
|
||||
for path in [*changes.new, *changes.modified]:
|
||||
entity_markdown = await self.entity_parser.parse_file(directory / path)
|
||||
parsed_entities[path] = entity_markdown
|
||||
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
# Add/update permalink in frontmatter
|
||||
logger.info(f"Adding permalink '{permalink}' to file: {path}")
|
||||
|
||||
# update markdown
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
|
||||
# update file frontmatter
|
||||
updated_checksum = await file_utils.update_frontmatter(
|
||||
directory / path, {"permalink": permalink}
|
||||
# First pass: Create/update entities
|
||||
# entities will have a null checksum to indicate they are not complete
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
# Get unique permalink and update markdown if needed
|
||||
permalink = await self.entity_service.resolve_permalink(
|
||||
Path(path), markdown=entity_markdown
|
||||
)
|
||||
|
||||
# Update checksum in changes report since file was modified
|
||||
changes.checksums[path] = updated_checksum
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
# Add/update permalink in frontmatter
|
||||
logger.info(f"Adding permalink '{permalink}' to file: {path}")
|
||||
|
||||
# if the file is new, create an entity
|
||||
if path in changes.new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity_markdown: {path}")
|
||||
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity_markdown: {path}")
|
||||
await self.entity_service.update_entity_and_observations(
|
||||
# update markdown
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
|
||||
# update file frontmatter
|
||||
updated_checksum = await file_utils.update_frontmatter(
|
||||
directory / path, {"permalink": permalink}
|
||||
)
|
||||
|
||||
# Update checksum in changes report since file was modified
|
||||
changes.checksums[path] = updated_checksum
|
||||
|
||||
# if the file is new, create an entity
|
||||
if path in changes.new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity_markdown: {path}")
|
||||
await self.entity_service.create_entity_from_markdown(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity_markdown: {path}")
|
||||
await self.entity_service.update_entity_and_observations(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
|
||||
# Second pass
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
logger.debug(f"Updating relations for: {path}")
|
||||
|
||||
# Process relations
|
||||
checksum = changes.checksums[path]
|
||||
entity = await self.entity_service.update_entity_relations(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
|
||||
# Second pass
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
logger.debug(f"Updating relations for: {path}")
|
||||
# add to search index
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
# Process relations
|
||||
checksum = changes.checksums[path]
|
||||
entity = await self.entity_service.update_entity_relations(Path(path), entity_markdown)
|
||||
# Set final checksum to mark sync complete
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
# add to search index
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
# Set final checksum to mark sync complete
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
# Third pass: Try to resolve any forward references
|
||||
logger.debug("Attempting to resolve forward references")
|
||||
for relation in await self.relation_repository.find_unresolved_relations():
|
||||
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
|
||||
# check we found a link that is not the source
|
||||
if target_entity and target_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
||||
# Third pass: Try to resolve any forward references
|
||||
logger.debug("Attempting to resolve forward references")
|
||||
for relation in await self.relation_repository.find_unresolved_relations():
|
||||
target_entity = await self.entity_service.link_resolver.resolve_link(
|
||||
relation.to_name
|
||||
)
|
||||
|
||||
try:
|
||||
await self.relation_repository.update(
|
||||
relation.id,
|
||||
{
|
||||
"to_id": target_entity.id,
|
||||
"to_name": target_entity.title, # Update to actual title
|
||||
},
|
||||
# check we found a link that is not the source
|
||||
if target_entity and target_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.debug(f"Ignoring duplicate relation {relation}")
|
||||
|
||||
# update search index
|
||||
await self.search_service.index_entity(target_entity)
|
||||
try:
|
||||
await self.relation_repository.update(
|
||||
relation.id,
|
||||
{
|
||||
"to_id": target_entity.id,
|
||||
"to_name": target_entity.title, # Update to actual title
|
||||
},
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.debug(f"Ignoring duplicate relation {relation}")
|
||||
|
||||
return changes
|
||||
# update search index
|
||||
await self.search_service.index_entity(target_entity)
|
||||
|
||||
return changes
|
||||
|
||||
@@ -65,13 +65,14 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
|
||||
|
||||
|
||||
def setup_logging(
|
||||
home_dir: Path = config.home, log_file: Optional[str] = None
|
||||
home_dir: Path = config.home, log_file: Optional[str] = None, console: bool = True
|
||||
) -> None: # pragma: no cover
|
||||
"""
|
||||
Configure logging for the application.
|
||||
:param home_dir: the root directory for the application
|
||||
:param log_file: the name of the log file to write to
|
||||
:param app: the fastapi application instance
|
||||
:param console: whether to log to the console
|
||||
"""
|
||||
|
||||
# Remove default handler and any existing handlers
|
||||
@@ -87,17 +88,13 @@ def setup_logging(
|
||||
root_path="/src/basic_memory",
|
||||
),
|
||||
environment=config.env,
|
||||
console=False,
|
||||
)
|
||||
logger.configure(handlers=[logfire.loguru_handler()])
|
||||
|
||||
# instrument code spans
|
||||
logfire.instrument_sqlite3()
|
||||
logfire.instrument_pydantic()
|
||||
|
||||
from basic_memory.db import _engine as engine
|
||||
|
||||
if engine:
|
||||
logfire.instrument_sqlalchemy(engine=engine)
|
||||
logfire.instrument_httpx()
|
||||
|
||||
# setup logger
|
||||
log_path = home_dir / log_file
|
||||
|
||||
@@ -26,6 +26,25 @@ async def test_get_memory_context(client, test_graph):
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pagination(client, test_graph):
|
||||
"""Test getting context from memory URL."""
|
||||
response = await client.get("/memory/test/root?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert context.primary_results[0].permalink == "test/root"
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata.uri == "test/root"
|
||||
assert context.metadata.depth == 1 # default depth
|
||||
# assert context.metadata["timeframe"] == "7d" # default timeframe
|
||||
assert isinstance(context.metadata.generated_at, datetime)
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pattern(client, test_graph):
|
||||
"""Test getting context with pattern matching."""
|
||||
@@ -91,6 +110,17 @@ async def test_recent_activity(client, test_graph):
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_pagination(client, test_graph):
|
||||
"""Test handling of paths."""
|
||||
response = await client.get("/memory/recent?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_by_type(client, test_graph):
|
||||
"""Test handling of non-existent paths."""
|
||||
|
||||
@@ -37,6 +37,35 @@ async def test_get_resource_content(client, test_config, entity_repository):
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink with pagination."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(test_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(f"/resource/{entity.permalink}", params={"page": 1, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_by_title(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink."""
|
||||
@@ -179,6 +208,54 @@ async def test_get_resource_entities(client, test_config, entity_repository):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_entities_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
assert entity1
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting second result
|
||||
response = await client.get("/resource/test/*", params={"page": 2, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Related Entity
|
||||
type: test
|
||||
permalink: test/related-entity
|
||||
---
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_relation(client, test_config, entity_repository):
|
||||
"""Test getting content by relation permalink."""
|
||||
|
||||
@@ -35,6 +35,18 @@ async def test_search_basic(client, indexed_entity):
|
||||
assert found, "Expected to find indexed entity in results"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic_pagination(client, indexed_entity):
|
||||
"""Test basic text search."""
|
||||
response = await client.post("/search/?page=3&page_size=1", json={"text": "search"})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
assert search_results.current_page == 3
|
||||
assert search_results.page_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client, indexed_entity):
|
||||
"""Test search with type filter."""
|
||||
@@ -92,28 +104,6 @@ async def test_search_with_date_filter(client, indexed_entity):
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.skip("search scoring is not implemented yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scoring(client, indexed_entity):
|
||||
"""Test search result scoring."""
|
||||
# Exact match should score higher
|
||||
exact_response = await client.post("/search/", json={"text": "TestComponent"})
|
||||
|
||||
# Partial match should score lower
|
||||
partial_response = await client.post("/search/", json={"text": "test"})
|
||||
|
||||
assert exact_response.status_code == 200
|
||||
assert partial_response.status_code == 200
|
||||
|
||||
exact_result = SearchResponse.model_validate(exact_response.json())
|
||||
partial_result = SearchResponse.model_validate(partial_response.json())
|
||||
|
||||
exact_score = exact_result.results[0].score
|
||||
partial_score = partial_result.results[0].score
|
||||
|
||||
assert exact_score > partial_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty(search_service, client):
|
||||
"""Test search with no matches."""
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for the Basic Memory CLI tools."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from basic_memory.cli.commands.tools import tool_app
|
||||
from basic_memory.schemas.response import EntityResponse
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_write_note():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_write_note", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "Created test/note.md (abc123)\npermalink: test/note"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_read_note():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_read_note", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "--- memory://test/note 2025-01 abc123\nTest content"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_search", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = SearchResponse(results=[], current_page=1, page_size=10)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_build_context():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_build_context", new_callable=AsyncMock) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = GraphContext(
|
||||
primary_results=[],
|
||||
related_results=[],
|
||||
metadata={
|
||||
"uri": "test/*",
|
||||
"depth": 1,
|
||||
"timeframe": "7d",
|
||||
"generated_at": now,
|
||||
"total_results": 0,
|
||||
"total_relations": 0,
|
||||
},
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_recent_activity():
|
||||
with patch(
|
||||
"basic_memory.cli.commands.tools.mcp_recent_activity", new_callable=AsyncMock
|
||||
) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = GraphContext(
|
||||
primary_results=[],
|
||||
related_results=[],
|
||||
metadata={
|
||||
"uri": None,
|
||||
"types": ["entity", "observation"],
|
||||
"depth": 1,
|
||||
"timeframe": "7d",
|
||||
"generated_at": now,
|
||||
"total_results": 0,
|
||||
"total_relations": 0,
|
||||
},
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_entity():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_get_entity", new_callable=AsyncMock) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = EntityResponse(
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
file_path="test/entity.md",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
observations=[],
|
||||
relations=[],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
def test_write_note(mock_write_note):
|
||||
"""Test write_note command with basic arguments."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--content",
|
||||
"Test content",
|
||||
"--folder",
|
||||
"test",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", None)
|
||||
|
||||
|
||||
def test_write_note_with_tags(mock_write_note):
|
||||
"""Test write_note command with tags."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--content",
|
||||
"Test content",
|
||||
"--folder",
|
||||
"test",
|
||||
"--tags",
|
||||
"tag1",
|
||||
"--tags",
|
||||
"tag2",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", ["tag1", "tag2"])
|
||||
|
||||
|
||||
def test_read_note(mock_read_note):
|
||||
"""Test read_note command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["read-note", "test/note"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_read_note.assert_awaited_once_with("test/note", 1, 10)
|
||||
|
||||
|
||||
def test_read_note_with_pagination(mock_read_note):
|
||||
"""Test read_note command with pagination."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["read-note", "test/note", "--page", "2", "--page-size", "5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_read_note.assert_awaited_once_with("test/note", 2, 5)
|
||||
|
||||
|
||||
def test_search_basic(mock_search):
|
||||
"""Test basic search command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test query"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].text == "test query"
|
||||
|
||||
|
||||
def test_search_permalink(mock_search):
|
||||
"""Test search with permalink flag."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test/*", "--permalink"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].permalink_match == "test/*"
|
||||
|
||||
|
||||
def test_search_title(mock_search):
|
||||
"""Test search with title flag."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test", "--title"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].title == "test"
|
||||
|
||||
|
||||
def test_search_with_pagination(mock_search):
|
||||
"""Test search with pagination."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test", "--page", "2", "--page-size", "5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["page"] == 2
|
||||
assert args["page_size"] == 5
|
||||
|
||||
|
||||
def test_build_context(mock_build_context):
|
||||
"""Test build_context command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["build-context", "memory://test/*"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_build_context.assert_awaited_once_with(
|
||||
url="memory://test/*", depth=1, timeframe="7d", page=1, page_size=10, max_related=10
|
||||
)
|
||||
|
||||
|
||||
def test_build_context_with_options(mock_build_context):
|
||||
"""Test build_context command with all options."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"build-context",
|
||||
"memory://test/*",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"1d",
|
||||
"--page",
|
||||
"2",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_build_context.assert_awaited_once_with(
|
||||
url="memory://test/*", depth=2, timeframe="1d", page=2, page_size=5, max_related=20
|
||||
)
|
||||
|
||||
|
||||
def test_get_entity(mock_get_entity):
|
||||
"""Test get_entity command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["get-entity", "test/entity"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_get_entity.assert_awaited_once_with(identifier="test/entity")
|
||||
|
||||
|
||||
def test_recent_activity(mock_recent_activity):
|
||||
"""Test recent_activity command with defaults."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["recent-activity"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_recent_activity.assert_awaited_once_with(
|
||||
type=["entity", "observation", "relation"],
|
||||
depth=1,
|
||||
timeframe="7d",
|
||||
page=1,
|
||||
page_size=10,
|
||||
max_related=10,
|
||||
)
|
||||
|
||||
|
||||
def test_recent_activity_with_options(mock_recent_activity):
|
||||
"""Test recent_activity command with options."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"recent-activity",
|
||||
"--type",
|
||||
"entity",
|
||||
"--type",
|
||||
"observation",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"1d",
|
||||
"--page",
|
||||
"2",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_recent_activity.assert_awaited_once_with(
|
||||
type=["entity", "observation"], depth=2, timeframe="1d", page=2, page_size=5, max_related=20
|
||||
)
|
||||
@@ -220,7 +220,7 @@ async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
|
||||
def test_import_chatgpt_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import", "chatgpt", "--conversations-json", str(nonexistent)])
|
||||
result = runner.invoke(app, ["import", "chatgpt", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
@@ -231,9 +231,7 @@ def test_import_chatgpt_command_success(tmp_path, sample_chatgpt_json, monkeypat
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
import_app, ["chatgpt", "--conversations-json", str(sample_chatgpt_json)]
|
||||
)
|
||||
result = runner.invoke(import_app, ["chatgpt", str(sample_chatgpt_json)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Imported 1 conversations" in result.output
|
||||
@@ -246,7 +244,7 @@ def test_import_chatgpt_command_invalid_json(tmp_path):
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(import_app, ["chatgpt", "--conversations-json", str(invalid_file)])
|
||||
result = runner.invoke(import_app, ["chatgpt", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
@@ -263,7 +261,6 @@ def test_import_chatgpt_with_custom_folder(tmp_path, sample_chatgpt_json, monkey
|
||||
[
|
||||
"import",
|
||||
"chatgpt",
|
||||
"--conversations-json",
|
||||
str(sample_chatgpt_json),
|
||||
"--folder",
|
||||
conversations_folder,
|
||||
|
||||
@@ -33,6 +33,30 @@ async def test_get_single_entity(client):
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_entity_memory_url(client):
|
||||
"""Test retrieving a single entity."""
|
||||
# First create an entity
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Get the entity
|
||||
entity = await get_entity("memory://test/test-note")
|
||||
|
||||
# Verify entity details
|
||||
assert entity.title == "Test Note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multiple_entities(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
@@ -59,6 +83,34 @@ async def test_get_multiple_entities(client):
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multiple_entities_memory_ur(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
# Create two test entities
|
||||
await notes.write_note(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="# Test 1",
|
||||
)
|
||||
await notes.write_note(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="# Test 2",
|
||||
)
|
||||
|
||||
# Get both entities
|
||||
request = GetEntitiesRequest(
|
||||
permalinks=["memory://test/test-note-1", "memory://test/test-note-2"]
|
||||
)
|
||||
response = await get_entities(request)
|
||||
|
||||
# Verify we got both entities
|
||||
assert len(response.entities) == 2
|
||||
permalinks = {e.permalink for e in response.entities}
|
||||
assert "test/test-note-1" in permalinks
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities(client):
|
||||
"""Test deleting entities."""
|
||||
@@ -81,6 +133,28 @@ async def test_delete_entities(client):
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities_memory_url(client):
|
||||
"""Test deleting entities."""
|
||||
# Create a test entity
|
||||
await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note to Delete",
|
||||
)
|
||||
|
||||
# Delete the entity
|
||||
request = DeleteEntitiesRequest(permalinks=["memory://test/test-note"])
|
||||
response = await delete_entities(request)
|
||||
|
||||
# Verify deletion
|
||||
assert response.deleted is True
|
||||
|
||||
# Verify entity no longer exists
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_entity(client):
|
||||
"""Test attempting to get a non-existent entity."""
|
||||
|
||||
@@ -89,7 +89,9 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await recent_activity(type=["entity"], timeframe=timeframe, max_results=1)
|
||||
result = await recent_activity(
|
||||
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
@@ -136,7 +138,9 @@ async def test_build_context_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await build_context(url=test_url, timeframe=timeframe, max_results=1)
|
||||
result = await build_context(
|
||||
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
|
||||
@@ -222,6 +222,35 @@ async def test_multiple_notes(app):
|
||||
assert "Content 3" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_notes_pagination(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("test/note-3", "Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await notes.read_note(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once with pagination
|
||||
result = await notes.read_note("test/*", page=1, page_size=2)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_existing(app):
|
||||
"""Test deleting a new note.
|
||||
|
||||
@@ -29,6 +29,27 @@ async def test_search_basic(client):
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_pagination(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await notes.write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
response = await search(query, page=1, page_size=1)
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) == 1
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client):
|
||||
"""Test search with entity type filter."""
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_search_response():
|
||||
metadata={},
|
||||
),
|
||||
]
|
||||
response = SearchResponse(results=results)
|
||||
response = SearchResponse(results=results, current_page=1, page_size=1)
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].score > response.results[1].score
|
||||
|
||||
|
||||
@@ -128,18 +128,6 @@ async def test_build_context(context_service, test_graph):
|
||||
assert total_results == len(primary_results) + len(related_results)
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_pattern(context_service, test_graph):
|
||||
"""Test permalink wildcard lookup."""
|
||||
# url = memory_url.validate_strings("memory://test/connected*")
|
||||
# results = await context_service.build_context(url)
|
||||
# matched_results = results["metadata"]["matched_results"]
|
||||
# primary_results = results["primary_results"]
|
||||
# related_results = results["related_results"]
|
||||
# total_results = results["metadata"]["total_results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_not_found(context_service):
|
||||
"""Test handling non-existent permalinks."""
|
||||
|
||||
@@ -92,14 +92,6 @@ async def test_exact_title_match(link_resolver, test_entities):
|
||||
assert entity.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Fuzzy misspelling not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_match_misspelling(link_resolver):
|
||||
# Test slight misspelling
|
||||
result = await link_resolver.resolve_link("Core Servise")
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_partial_match(link_resolver):
|
||||
# Test partial match
|
||||
|
||||
@@ -19,6 +19,25 @@ async def test_search_permalink(search_service, test_graph):
|
||||
assert "test/root" in r.permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_limit_offset(search_service, test_graph):
|
||||
"""Exact permalink"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"))
|
||||
assert len(results) > 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=1)
|
||||
assert len(results) == 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=100)
|
||||
num_results = len(results)
|
||||
|
||||
# assert offset
|
||||
offset_results = await search_service.search(
|
||||
SearchQuery(permalink_match="test/*"), limit=100, offset=1
|
||||
)
|
||||
assert len(offset_results) == num_results - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_observations_wildcard(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
|
||||
@@ -79,7 +79,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basic-memory"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -88,7 +88,7 @@ dependencies = [
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "greenlet" },
|
||||
{ name = "icecream" },
|
||||
{ name = "logfire", extra = ["fastapi", "sqlalchemy", "sqlite3"] },
|
||||
{ name = "logfire", extra = ["fastapi", "httpx", "sqlalchemy", "sqlite3"] },
|
||||
{ name = "loguru" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mcp" },
|
||||
@@ -126,7 +126,7 @@ requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" },
|
||||
{ name = "greenlet", specifier = ">=3.1.1" },
|
||||
{ name = "icecream", specifier = ">=2.1.3" },
|
||||
{ name = "logfire", extras = ["fastapi", "sqlalchemy", "sqlite3"], specifier = ">=3.6.0" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "sqlalchemy", "sqlite3"], specifier = ">=3.6.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "markdown-it-py", specifier = ">=3.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.2.0" },
|
||||
@@ -680,6 +680,9 @@ wheels = [
|
||||
fastapi = [
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
]
|
||||
httpx = [
|
||||
{ name = "opentelemetry-instrumentation-httpx" },
|
||||
]
|
||||
sqlalchemy = [
|
||||
{ name = "opentelemetry-instrumentation-sqlalchemy" },
|
||||
]
|
||||
@@ -913,6 +916,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-httpx"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/d5/4a3990c461ae7e55212115e0f8f3aa412b5ce6493579e85c292245ac69ea/opentelemetry_instrumentation_httpx-0.51b0.tar.gz", hash = "sha256:061d426a04bf5215a859fea46662e5074f920e5cbde7e6ad6825a0a1b595802c", size = 17700 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/ba/23d4ab6402408c01f1c3f32e0c04ea6dae575bf19bcb9a0049c9e768c983/opentelemetry_instrumentation_httpx-0.51b0-py3-none-any.whl", hash = "sha256:2e3fdf755ba6ead6ab43031497c3d55d4c796d0368eccc0ce48d304b7ec6486a", size = 14109 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-sqlalchemy"
|
||||
version = "0.51b0"
|
||||
|
||||
Reference in New Issue
Block a user