diff --git a/src/basic_memory/api/routers/knowledge_router.py b/src/basic_memory/api/routers/knowledge_router.py index c70b3819..c3bc23a4 100644 --- a/src/basic_memory/api/routers/knowledge_router.py +++ b/src/basic_memory/api/routers/knowledge_router.py @@ -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 diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index dbd80686..8fa0cbc1 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -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)) diff --git a/src/basic_memory/cli/commands/import_chatgpt.py b/src/basic_memory/cli/commands/import_chatgpt.py index 0769b10c..e0f71f84 100644 --- a/src/basic_memory/cli/commands/import_chatgpt.py +++ b/src/basic_memory/cli/commands/import_chatgpt.py @@ -210,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.") diff --git a/src/basic_memory/cli/commands/tools.py b/src/basic_memory/cli/commands/tools.py new file mode 100644 index 00000000..439029f3 --- /dev/null +++ b/src/basic_memory/cli/commands/tools.py @@ -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, +): # pragma: no cover + try: + note = asyncio.run(mcp_write_note(title, content, folder, tags)) + rprint(note) + except Exception as e: + 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): # pragma: no cover + try: + note = asyncio.run(mcp_read_note(identifier, page, page_size)) + rprint(note) + except Exception as e: + 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, +): # pragma: no cover + 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, +): # pragma: no cover + + if type not in ["entity", "observation", "relation"]: + print("type must be one of ['entity', 'observation', 'relation']") + raise typer.Abort() + + try: + context = asyncio.run( + mcp_recent_activity( + type=type, + 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: + 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 diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index 0dd9697a..18aed9c0 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -12,6 +12,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover import_claude_conversations, import_claude_projects, import_chatgpt, + tools, ) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 95259018..b7b2a129 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -29,5 +29,10 @@ async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> Sear """ 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}) + response = await call_post( + client, + "/search/", + json=query.model_dump(), + params={"page": page, "page_size": page_size}, + ) return SearchResponse.model_validate(response.json()) diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 9a075cdd..4e8d1227 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -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 diff --git a/tests/cli/test_import_chatgpt.py b/tests/cli/test_import_chatgpt.py index 96797220..e1cc3e52 100644 --- a/tests/cli/test_import_chatgpt.py +++ b/tests/cli/test_import_chatgpt.py @@ -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,