From f17bb385c094c3fad9bea70e1cba8d1542862735 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sun, 22 Mar 2026 16:18:52 +0000 Subject: [PATCH] Add memory_manager source files and tests --- src/memory_manager/cli/main.py | 375 ++++++++++++++++++++------------- 1 file changed, 234 insertions(+), 141 deletions(-) diff --git a/src/memory_manager/cli/main.py b/src/memory_manager/cli/main.py index ae6a153..390c052 100644 --- a/src/memory_manager/cli/main.py +++ b/src/memory_manager/cli/main.py @@ -1,244 +1,337 @@ -"""CLI entry point for memory manager.""" +import asyncio import os -import sys -from typing import Optional +from datetime import datetime import click -import uvicorn -from memory_manager.core.services import MemoryService, CommitService +from memory_manager import __version__ +from memory_manager.core.services import MemoryManager +from memory_manager.db.models import MemoryCategory from memory_manager.db.repository import MemoryRepository -from memory_manager.api.app import app -db_path = os.getenv("MEMORY_DB_PATH", ".memory/codebase_memory.db") -os.makedirs(os.path.dirname(db_path), exist_ok=True) +def get_db_path() -> str: + return os.getenv("MEMORY_DB_PATH", ".memory/codebase_memory.db") -repository = MemoryRepository(db_path) -memory_service = MemoryService(repository) -commit_service = CommitService(repository) + +async def get_memory_manager() -> MemoryManager: + repository = MemoryRepository(get_db_path()) + await repository.initialize() + manager = MemoryManager(repository) + return manager + + +def validate_category(ctx, param, value): + if value is None: + return None + try: + return MemoryCategory(value) + except ValueError: + raise click.BadParameter(f"Invalid category. Must be one of: {[c.value for c in MemoryCategory]}") @click.group() -@click.version_option(version="0.1.0") +@click.version_option(version=__version__) def cli(): - """Agentic Codebase Memory Manager - A shared memory layer for AI coding agents.""" + """Agentic Codebase Memory Manager - A centralized memory store for AI coding agents.""" pass -@cli.command() -@click.option("--title", required=True, help="Entry title") -@click.option("--content", required=True, help="Entry content") -@click.option("--category", required=True, type=click.Choice(["decision", "feature", "refactoring", "architecture", "bug", "note"]), help="Entry category") -@click.option("--tags", default="", help="Comma-separated tags") -@click.option("--agent-id", default=lambda: os.getenv("AGENT_ID", "unknown"), help="Agent identifier") -def add(title: str, content: str, category: str, tags: str, agent_id: str): +@click.command() +@click.option("--title", "-t", required=True, help="Entry title") +@click.option("--content", "-c", required=True, help="Entry content") +@click.option("--category", "-g", required=True, callback=validate_category, help="Entry category") +@click.option("--tags", "-T", multiple=True, help="Entry tags") +@click.option("--agent-id", help="Agent ID (defaults to AGENT_ID env var)") +@click.option("--project-path", help="Project path (defaults to MEMORY_PROJECT_PATH env var)") +def add(title, content, category, tags, agent_id, project_path): """Add a new memory entry.""" - import asyncio + asyncio.run(_add(title, content, category, list(tags), agent_id, project_path)) - async def _add(): - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - entry = await memory_service.create_entry( + +async def _add(title, content, category, tags, agent_id, project_path): + manager = await get_memory_manager() + try: + entry = await manager.memory_service.create_entry( title=title, content=content, category=category, - tags=tag_list, + tags=tags, agent_id=agent_id, + project_path=project_path, ) - click.echo(f"Created entry {entry.id}: {entry.title}") - - asyncio.run(_add()) + click.echo(f"Created entry {entry['id']}: {entry['title']}") + finally: + await manager.close() -@cli.command() -@click.option("--category", help="Filter by category") -@click.option("--agent-id", help="Filter by agent") -@click.option("--limit", default=100, help="Maximum entries to return") -def list(category: Optional[str], agent_id: Optional[str], limit: int): +@click.command() +@click.option("--category", "-g", callback=validate_category, help="Filter by category") +@click.option("--agent-id", help="Filter by agent ID") +@click.option("--project-path", help="Filter by project path") +@click.option("--limit", "-n", default=100, help="Number of entries to show") +@click.option("--offset", default=0, help="Offset for pagination") +def list(category, agent_id, project_path, limit, offset): """List memory entries.""" - import asyncio + asyncio.run(_list(category, agent_id, project_path, limit, offset)) - async def _list(): - entries = await memory_service.list_entries( + +async def _list(category, agent_id, project_path, limit, offset): + manager = await get_memory_manager() + try: + entries = await manager.memory_service.list_entries( category=category, agent_id=agent_id, + project_path=project_path, limit=limit, + offset=offset, ) if not entries: click.echo("No entries found.") return + for entry in entries: - click.echo(f"[{entry.id}] {entry.category.value}: {entry.title}") - click.echo(f" {entry.content[:100]}...") - click.echo(f" Tags: {', '.join(entry.tags or [])}") + created = entry["created_at"] + if created: + created = datetime.fromisoformat(created).strftime("%Y-%m-%d %H:%M") + click.echo(f"[{entry['id']}] {entry['category']} | {entry['title']} | {created} | {entry['agent_id']}") + click.echo(f" {entry['content'][:100]}...") + if entry["tags"]: + click.echo(f" Tags: {', '.join(entry['tags'])}") click.echo() - - asyncio.run(_list()) + finally: + await manager.close() -@cli.command() +@click.command() @click.argument("query") -@click.option("--category", help="Filter by category") -def search(query: str, category: Optional[str]): +@click.option("--category", "-g", callback=validate_category, help="Filter by category") +@click.option("--agent-id", help="Filter by agent ID") +@click.option("--project-path", help="Filter by project path") +@click.option("--limit", "-n", default=100, help="Number of results") +def search(query, category, agent_id, project_path, limit): """Search memory entries.""" - import asyncio + asyncio.run(_search(query, category, agent_id, project_path, limit)) - async def _search(): - entries = await memory_service.search_entries( + +async def _search(query, category, agent_id, project_path, limit): + manager = await get_memory_manager() + try: + results = await manager.search_service.search( query=query, category=category, + agent_id=agent_id, + project_path=project_path, + limit=limit, ) - if not entries: - click.echo(f"No entries found matching '{query}'.") + if not results: + click.echo("No results found.") return - click.echo(f"Found {len(entries)} entries:") - for entry in entries: - click.echo(f"[{entry.id}] {entry.category.value}: {entry.title}") - asyncio.run(_search()) + click.echo(f"Found {len(results)} result(s):\n") + for entry in results: + created = entry["created_at"] + if created: + created = datetime.fromisoformat(created).strftime("%Y-%m-%d %H:%M") + click.echo(f"[{entry['id']}] {entry['category']} | {entry['title']} | {created}") + click.echo(f" {entry['content'][:200]}...") + if entry["tags"]: + click.echo(f" Tags: {', '.join(entry['tags'])}") + click.echo() + finally: + await manager.close() -@cli.command() +@click.command() @click.argument("entry_id", type=int) -def get(entry_id: int): - """Get a specific entry.""" - import asyncio +def get(entry_id): + """Get a specific memory entry by ID.""" + asyncio.run(_get(entry_id)) - async def _get(): - entry = await memory_service.get_entry(entry_id) + +async def _get(entry_id): + manager = await get_memory_manager() + try: + entry = await manager.memory_service.get_entry(entry_id) if not entry: - click.echo(f"Entry {entry_id} not found.") + click.echo(f"Entry {entry_id} not found.", err=True) return - click.echo(f"Title: {entry.title}") - click.echo(f"Category: {entry.category.value}") - click.echo(f"Content: {entry.content}") - click.echo(f"Tags: {', '.join(entry.tags or [])}") - click.echo(f"Agent: {entry.agent_id}") - click.echo(f"Created: {entry.created_at}") - click.echo(f"Updated: {entry.updated_at}") - asyncio.run(_get()) + click.echo(f"ID: {entry['id']}") + click.echo(f"Title: {entry['title']}") + click.echo(f"Category: {entry['category']}") + click.echo(f"Agent: {entry['agent_id']}") + click.echo(f"Project: {entry['project_path']}") + click.echo(f"Tags: {', '.join(entry['tags']) if entry['tags'] else '(none)'}") + click.echo(f"Created: {entry['created_at']}") + click.echo(f"Updated: {entry['updated_at']}") + click.echo(f"\nContent:\n{entry['content']}") + finally: + await manager.close() -@cli.command() +@click.command() @click.argument("entry_id", type=int) -@click.option("--title", help="New title") -@click.option("--content", help="New content") -@click.option("--category", type=click.Choice(["decision", "feature", "refactoring", "architecture", "bug", "note"]), help="New category") -@click.option("--tags", help="Comma-separated tags") -def update(entry_id: int, title: Optional[str], content: Optional[str], category: Optional[str], tags: Optional[str]): +@click.option("--title", "-t", help="New title") +@click.option("--content", "-c", help="New content") +@click.option("--category", "-g", callback=validate_category, help="New category") +@click.option("--tags", "-T", multiple=True, help="New tags") +def update(entry_id, title, content, category, tags): """Update a memory entry.""" - import asyncio + asyncio.run(_update(entry_id, title, content, category, list(tags) if tags else None)) - async def _update(): - tag_list = [t.strip() for t in tags.split(",")] if tags else None - updated = await memory_service.update_entry( + +async def _update(entry_id, title, content, category, tags): + manager = await get_memory_manager() + try: + result = await manager.memory_service.update_entry( entry_id=entry_id, title=title, content=content, category=category, - tags=tag_list, + tags=tags, ) - if not updated: - click.echo(f"Entry {entry_id} not found.") + if not result: + click.echo(f"Entry {entry_id} not found.", err=True) return - click.echo(f"Updated entry {entry_id}: {updated.title}") - - asyncio.run(_update()) + click.echo(f"Updated entry {entry_id}: {result['title']}") + finally: + await manager.close() -@cli.command() +@click.command() @click.argument("entry_id", type=int) -def delete(entry_id: int): +def delete(entry_id): """Delete a memory entry.""" - import asyncio + asyncio.run(_delete(entry_id)) - async def _delete(): - deleted = await memory_service.delete_entry(entry_id) + +async def _delete(entry_id): + manager = await get_memory_manager() + try: + deleted = await manager.memory_service.delete_entry(entry_id) if not deleted: - click.echo(f"Entry {entry_id} not found.") + click.echo(f"Entry {entry_id} not found.", err=True) return click.echo(f"Deleted entry {entry_id}.") - - asyncio.run(_delete()) + finally: + await manager.close() -@cli.command() -@click.option("--message", required=True, help="Commit message") -@click.option("--agent-id", default=lambda: os.getenv("AGENT_ID", "unknown"), help="Agent identifier") -def commit(message: str, agent_id: str): +@click.command() +@click.option("--message", "-m", required=True, help="Commit message") +@click.option("--agent-id", help="Agent ID") +@click.option("--project-path", help="Project path") +def commit(message, agent_id, project_path): """Create a commit snapshot of current memory state.""" - import asyncio + asyncio.run(_commit(message, agent_id, project_path)) - async def _commit(): - new_commit = await commit_service.create_commit( + +async def _commit(message, agent_id, project_path): + manager = await get_memory_manager() + try: + result = await manager.commit_service.create_commit( message=message, agent_id=agent_id, + project_path=project_path, ) - click.echo(f"Created commit {new_commit.hash[:8]}: {message}") - - asyncio.run(_commit()) + click.echo(f"Created commit {result['hash']}: {result['message']}") + finally: + await manager.close() -@cli.command() -@click.option("--limit", default=50, help="Maximum commits to show") -def log(limit: int): +@click.command() +@click.option("--agent-id", help="Filter by agent ID") +@click.option("--project-path", help="Filter by project path") +@click.option("--limit", "-n", default=100, help="Number of commits to show") +@click.option("--offset", default=0, help="Offset for pagination") +def log(agent_id, project_path, limit, offset): """Show commit history.""" - import asyncio + asyncio.run(_log(agent_id, project_path, limit, offset)) - async def _log(): - commits = await commit_service.get_commits(limit=limit) + +async def _log(agent_id, project_path, limit, offset): + manager = await get_memory_manager() + try: + commits = await manager.commit_service.list_commits( + agent_id=agent_id, + project_path=project_path, + limit=limit, + offset=offset, + ) if not commits: - click.echo("No commits yet.") + click.echo("No commits found.") return + for commit in commits: - click.echo(f"{commit.hash[:8]} - {commit.message}") - click.echo(f" Agent: {commit.agent_id} | {commit.created_at}") - click.echo() - - asyncio.run(_log()) + created = commit["created_at"] + if created: + created = datetime.fromisoformat(created).strftime("%Y-%m-%d %H:%M") + click.echo(f"commit {commit['hash']}") + click.echo(f"Author: {commit['agent_id']}") + click.echo(f"Date: {created}") + click.echo(f"\n {commit['message']}\n") + finally: + await manager.close() -@cli.command() +@click.command() @click.argument("hash1") @click.argument("hash2") -def diff(hash1: str, hash2: str): +def diff(hash1, hash2): """Show diff between two commits.""" - import asyncio + asyncio.run(_diff(hash1, hash2)) - async def _diff(): - diff_result = await commit_service.diff_commits(hash1, hash2) - if diff_result["added"]: + +async def _diff(hash1, hash2): + manager = await get_memory_manager() + try: + result = await manager.commit_service.diff(hash1, hash2) + if not result: + click.echo("One or both commits not found. Check available commits with 'memory log'.", err=True) + return + + if result["added"]: click.echo("Added entries:") - for entry in diff_result["added"]: - click.echo(f" + {entry['title']}") - if diff_result["removed"]: - click.echo("Removed entries:") - for entry in diff_result["removed"]: - click.echo(f" - {entry['title']}") - if diff_result["modified"]: - click.echo("Modified entries:") - for mod in diff_result["modified"]: - click.echo(f" ~ {mod['before']['title']}") - if not any([diff_result["added"], diff_result["removed"], diff_result["modified"]]): + for entry in result["added"]: + click.echo(f" + [{entry['id']}] {entry['title']}") + if result["removed"]: + click.echo("\nRemoved entries:") + for entry in result["removed"]: + click.echo(f" - [{entry['id']}] {entry['title']}") + if result["modified"]: + click.echo("\nModified entries:") + for mod in result["modified"]: + click.echo(f" ~ [{mod['after']['id']}] {mod['after']['title']}") + + if not any([result["added"], result["removed"], result["modified"]]): click.echo("No differences found.") - - asyncio.run(_diff()) + finally: + await manager.close() -@cli.command() -@click.option("--host", default=lambda: os.getenv("MEMORY_API_HOST", "127.0.0.1"), help="Host to bind to") -@click.option("--port", default=lambda: int(os.getenv("MEMORY_API_PORT", "8080")), type=int, help="Port to bind to") -def serve(host: str, port: int): +@click.command() +@click.option("--host", default=os.getenv("MEMORY_API_HOST", "127.0.0.1"), help="Server host") +@click.option("--port", default=int(os.getenv("MEMORY_API_PORT", "8080")), help="Server port") +def serve(host, port): """Start the API server.""" - uvicorn.run(app, host=host, port=port) + import uvicorn + + from memory_manager.api.app import app as fastapi_app + + click.echo(f"Starting server at http://{host}:{port}") + click.echo(f"API documentation available at http://{host}:{port}/docs") + + uvicorn.run(fastapi_app, host=host, port=port) -@cli.command() +@click.command() def tui(): """Launch the TUI dashboard.""" - from memory_manager.tui.app import run_tui - run_tui() + from memory_manager.tui.app import TUIApp + + app = TUIApp() + app.run() if __name__ == "__main__":