Re-upload: CI infrastructure issue resolved, all tests verified passing
This commit is contained in:
3
src/memory_manager/__init__.py
Normal file
3
src/memory_manager/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Agentic Codebase Memory Manager - A centralized memory store for AI coding agents."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
src/memory_manager/api/__init__.py
Normal file
0
src/memory_manager/api/__init__.py
Normal file
207
src/memory_manager/api/app.py
Normal file
207
src/memory_manager/api/app.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""FastAPI REST API for the memory manager."""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from memory_manager import __version__
|
||||
from memory_manager.api.schemas import (
|
||||
CommitCreate,
|
||||
CommitResponse,
|
||||
DiffResponse,
|
||||
HealthResponse,
|
||||
MemoryEntryCreate,
|
||||
MemoryEntryResponse,
|
||||
MemoryEntryUpdate,
|
||||
StatsResponse,
|
||||
)
|
||||
from memory_manager.core.services import MemoryManager
|
||||
from memory_manager.db.models import MemoryCategory
|
||||
from memory_manager.db.repository import MemoryRepository
|
||||
|
||||
db_path = os.getenv("MEMORY_DB_PATH", ".memory/codebase_memory.db")
|
||||
repository = MemoryRepository(db_path)
|
||||
memory_manager = MemoryManager(repository)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await memory_manager.initialize()
|
||||
yield
|
||||
await memory_manager.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Agentic Codebase Memory Manager",
|
||||
description="A centralized memory store for AI coding agents",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health():
|
||||
return HealthResponse(status="ok", version=__version__)
|
||||
|
||||
|
||||
@app.get("/api/memory", response_model=list[MemoryEntryResponse])
|
||||
async def list_memory(
|
||||
category: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
category_enum = None
|
||||
if category:
|
||||
try:
|
||||
category_enum = MemoryCategory(category)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid category: {category}")
|
||||
|
||||
entries = await memory_manager.memory_service.list_entries(
|
||||
category=category_enum,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
@app.post("/api/memory", response_model=MemoryEntryResponse, status_code=201)
|
||||
async def create_memory(entry: MemoryEntryCreate):
|
||||
result = await memory_manager.memory_service.create_entry(
|
||||
title=entry.title,
|
||||
content=entry.content,
|
||||
category=entry.category,
|
||||
tags=entry.tags,
|
||||
agent_id=entry.agent_id,
|
||||
project_path=entry.project_path,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/memory/log", response_model=list[CommitResponse])
|
||||
async def get_log(
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
commits = await memory_manager.commit_service.list_commits(
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return commits
|
||||
|
||||
|
||||
@app.get("/api/memory/stats", response_model=StatsResponse)
|
||||
async def get_stats(project_path: str | None = None):
|
||||
entries = await memory_manager.memory_service.list_entries(
|
||||
project_path=project_path,
|
||||
limit=10000,
|
||||
)
|
||||
|
||||
entries_by_category: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
cat = entry["category"]
|
||||
entries_by_category[cat] = entries_by_category.get(cat, 0) + 1
|
||||
|
||||
commits = await memory_manager.commit_service.list_commits(
|
||||
project_path=project_path,
|
||||
limit=10000,
|
||||
)
|
||||
|
||||
return StatsResponse(
|
||||
total_entries=len(entries),
|
||||
entries_by_category=entries_by_category,
|
||||
total_commits=len(commits),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/memory/search", response_model=list[MemoryEntryResponse])
|
||||
async def search_memory(
|
||||
q: str = Query(..., min_length=1),
|
||||
category: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
):
|
||||
category_enum = None
|
||||
if category:
|
||||
try:
|
||||
category_enum = MemoryCategory(category)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid category: {category}")
|
||||
|
||||
results = await memory_manager.search_service.search(
|
||||
query=q,
|
||||
category=category_enum,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/api/memory/commit", response_model=CommitResponse, status_code=201)
|
||||
async def create_commit(commit: CommitCreate):
|
||||
result = await memory_manager.commit_service.create_commit(
|
||||
message=commit.message,
|
||||
agent_id=commit.agent_id,
|
||||
project_path=commit.project_path,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/memory/diff/{hash1}/{hash2}", response_model=DiffResponse)
|
||||
async def get_diff(hash1: str, hash2: str):
|
||||
diff = await memory_manager.commit_service.diff(hash1, hash2)
|
||||
if not diff:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Commit(s) not found: {hash1}, {hash2}. Check available commits with /api/memory/log"
|
||||
)
|
||||
return diff
|
||||
|
||||
|
||||
@app.get("/api/memory/{entry_id}", response_model=MemoryEntryResponse)
|
||||
async def get_memory(entry_id: int):
|
||||
entry = await memory_manager.memory_service.get_entry(entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail=f"Entry {entry_id} not found")
|
||||
return entry
|
||||
|
||||
|
||||
@app.put("/api/memory/{entry_id}", response_model=MemoryEntryResponse)
|
||||
async def update_memory(entry_id: int, entry: MemoryEntryUpdate):
|
||||
result = await memory_manager.memory_service.update_entry(
|
||||
entry_id=entry_id,
|
||||
title=entry.title,
|
||||
content=entry.content,
|
||||
category=entry.category,
|
||||
tags=entry.tags,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"Entry {entry_id} not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/memory/{entry_id}", status_code=204)
|
||||
async def delete_memory(entry_id: int):
|
||||
deleted = await memory_manager.memory_service.delete_entry(entry_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Entry {entry_id} not found")
|
||||
79
src/memory_manager/api/schemas.py
Normal file
79
src/memory_manager/api/schemas.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Pydantic schemas for API request/response validation."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from memory_manager.db.models import MemoryCategory
|
||||
|
||||
|
||||
class MemoryEntryCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(..., min_length=1)
|
||||
category: MemoryCategory
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
agent_id: str | None = None
|
||||
project_path: str | None = None
|
||||
|
||||
|
||||
class MemoryEntryUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=255)
|
||||
content: str | None = Field(None, min_length=1)
|
||||
category: MemoryCategory | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class MemoryEntryResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
category: str
|
||||
tags: list[str]
|
||||
agent_id: str
|
||||
project_path: str
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class SearchQuery(BaseModel):
|
||||
q: str = Field(..., min_length=1)
|
||||
category: MemoryCategory | None = None
|
||||
agent_id: str | None = None
|
||||
project_path: str | None = None
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
|
||||
|
||||
class CommitCreate(BaseModel):
|
||||
message: str = Field(..., min_length=1)
|
||||
agent_id: str | None = None
|
||||
project_path: str | None = None
|
||||
|
||||
|
||||
class CommitResponse(BaseModel):
|
||||
id: int
|
||||
hash: str
|
||||
message: str
|
||||
agent_id: str
|
||||
project_path: str
|
||||
snapshot: list[dict[str, Any]]
|
||||
created_at: datetime | None
|
||||
|
||||
|
||||
class DiffResponse(BaseModel):
|
||||
commit1: dict[str, Any]
|
||||
commit2: dict[str, Any]
|
||||
added: list[dict[str, Any]]
|
||||
removed: list[dict[str, Any]]
|
||||
modified: list[dict[str, Any]]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
total_entries: int
|
||||
entries_by_category: dict[str, int]
|
||||
total_commits: int
|
||||
0
src/memory_manager/cli/__init__.py
Normal file
0
src/memory_manager/cli/__init__.py
Normal file
340
src/memory_manager/cli/main.py
Normal file
340
src/memory_manager/cli/main.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""CLI interface for the memory manager using Click."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import click
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_db_path() -> str:
|
||||
return os.getenv("MEMORY_DB_PATH", ".memory/codebase_memory.db")
|
||||
|
||||
|
||||
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=__version__)
|
||||
def cli():
|
||||
"""Agentic Codebase Memory Manager - A centralized memory store for AI coding agents."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.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."""
|
||||
asyncio.run(_add(title, content, category, list(tags), agent_id, project_path))
|
||||
|
||||
|
||||
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=tags,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
)
|
||||
click.echo(f"Created entry {entry['id']}: {entry['title']}")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.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."""
|
||||
asyncio.run(_list(category, agent_id, project_path, limit, offset))
|
||||
|
||||
|
||||
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:
|
||||
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()
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("query")
|
||||
@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."""
|
||||
asyncio.run(_search(query, category, agent_id, project_path, limit))
|
||||
|
||||
|
||||
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 results:
|
||||
click.echo("No results found.")
|
||||
return
|
||||
|
||||
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.argument("entry_id", type=int)
|
||||
def get(entry_id):
|
||||
"""Get a specific memory entry by ID."""
|
||||
asyncio.run(_get(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.", err=True)
|
||||
return
|
||||
|
||||
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.argument("entry_id", type=int)
|
||||
@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."""
|
||||
asyncio.run(_update(entry_id, title, content, category, list(tags) if tags else None))
|
||||
|
||||
|
||||
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=tags,
|
||||
)
|
||||
if not result:
|
||||
click.echo(f"Entry {entry_id} not found.", err=True)
|
||||
return
|
||||
click.echo(f"Updated entry {entry_id}: {result['title']}")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("entry_id", type=int)
|
||||
def delete(entry_id):
|
||||
"""Delete a memory entry."""
|
||||
asyncio.run(_delete(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.", err=True)
|
||||
return
|
||||
click.echo(f"Deleted entry {entry_id}.")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.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."""
|
||||
asyncio.run(_commit(message, agent_id, project_path))
|
||||
|
||||
|
||||
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 {result['hash']}: {result['message']}")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.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."""
|
||||
asyncio.run(_log(agent_id, project_path, limit, offset))
|
||||
|
||||
|
||||
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 found.")
|
||||
return
|
||||
|
||||
for commit in commits:
|
||||
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.argument("hash1")
|
||||
@click.argument("hash2")
|
||||
def diff(hash1, hash2):
|
||||
"""Show diff between two commits."""
|
||||
asyncio.run(_diff(hash1, hash2))
|
||||
|
||||
|
||||
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 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.")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@cli.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."""
|
||||
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()
|
||||
def tui():
|
||||
"""Launch the TUI dashboard."""
|
||||
from memory_manager.tui.app import TUIApp
|
||||
|
||||
app = TUIApp()
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
0
src/memory_manager/core/__init__.py
Normal file
0
src/memory_manager/core/__init__.py
Normal file
210
src/memory_manager/core/services.py
Normal file
210
src/memory_manager/core/services.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Core business logic services for the memory manager."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from memory_manager.db.models import MemoryCategory
|
||||
from memory_manager.db.repository import MemoryRepository
|
||||
|
||||
|
||||
class MemoryService:
|
||||
def __init__(self, repository: MemoryRepository):
|
||||
self.repository = repository
|
||||
|
||||
async def create_entry(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
category: str | MemoryCategory,
|
||||
tags: list[str] | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(category, str):
|
||||
category = MemoryCategory(category)
|
||||
|
||||
agent_id = agent_id or os.getenv("AGENT_ID", "unknown") or "unknown"
|
||||
project_path = project_path or os.getenv("MEMORY_PROJECT_PATH", ".") or "."
|
||||
|
||||
entry = await self.repository.create_entry(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
tags=tags or [],
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
)
|
||||
return entry.to_dict()
|
||||
|
||||
async def get_entry(self, entry_id: int) -> dict[str, Any] | None:
|
||||
entry = await self.repository.get_entry(entry_id)
|
||||
return entry.to_dict() if entry else None
|
||||
|
||||
async def update_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | MemoryCategory | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if category is not None and isinstance(category, str):
|
||||
category = MemoryCategory(category)
|
||||
|
||||
entry = await self.repository.update_entry(
|
||||
entry_id=entry_id,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
return entry.to_dict() if entry else None
|
||||
|
||||
async def delete_entry(self, entry_id: int) -> bool:
|
||||
return await self.repository.delete_entry(entry_id)
|
||||
|
||||
async def list_entries(
|
||||
self,
|
||||
category: str | MemoryCategory | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
if category is not None and isinstance(category, str):
|
||||
category = MemoryCategory(category)
|
||||
|
||||
entries = await self.repository.list_entries(
|
||||
category=category,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [entry.to_dict() for entry in entries]
|
||||
|
||||
|
||||
class SearchService:
|
||||
def __init__(self, repository: MemoryRepository):
|
||||
self.repository = repository
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
category: str | MemoryCategory | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
if category is not None and isinstance(category, str):
|
||||
category = MemoryCategory(category)
|
||||
|
||||
entries = await self.repository.search_entries(
|
||||
query_text=query,
|
||||
category=category,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
)
|
||||
return [entry.to_dict() for entry in entries]
|
||||
|
||||
|
||||
class CommitService:
|
||||
def __init__(self, repository: MemoryRepository):
|
||||
self.repository = repository
|
||||
|
||||
def _generate_hash(self, data: str) -> str:
|
||||
return hashlib.sha1(data.encode()).hexdigest()
|
||||
|
||||
async def create_commit(
|
||||
self,
|
||||
message: str,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id or os.getenv("AGENT_ID", "unknown") or "unknown"
|
||||
project_path = project_path or os.getenv("MEMORY_PROJECT_PATH", ".") or "."
|
||||
|
||||
snapshot = await self.repository.get_all_entries_snapshot(project_path)
|
||||
|
||||
snapshot_str = f"{snapshot}{message}{agent_id}"
|
||||
hash = self._generate_hash(snapshot_str)
|
||||
|
||||
commit = await self.repository.create_commit(
|
||||
hash=hash,
|
||||
message=message,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
return commit.to_dict()
|
||||
|
||||
async def get_commit(self, hash: str) -> dict[str, Any] | None:
|
||||
commit = await self.repository.get_commit(hash)
|
||||
return commit.to_dict() if commit else None
|
||||
|
||||
async def list_commits(
|
||||
self,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
commits = await self.repository.list_commits(
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [commit.to_dict() for commit in commits]
|
||||
|
||||
async def diff(self, hash1: str, hash2: str) -> dict[str, Any] | None:
|
||||
commit1 = await self.repository.get_commit(hash1)
|
||||
commit2 = await self.repository.get_commit(hash2)
|
||||
|
||||
if not commit1 or not commit2:
|
||||
return None
|
||||
|
||||
snapshot1 = {entry["id"]: entry for entry in commit1.snapshot}
|
||||
snapshot2 = {entry["id"]: entry for entry in commit2.snapshot}
|
||||
|
||||
all_ids = set(snapshot1.keys()) | set(snapshot2.keys())
|
||||
|
||||
added = []
|
||||
removed = []
|
||||
modified = []
|
||||
|
||||
for entry_id in all_ids:
|
||||
if entry_id not in snapshot1:
|
||||
added.append(snapshot2[entry_id])
|
||||
elif entry_id not in snapshot2:
|
||||
removed.append(snapshot1[entry_id])
|
||||
else:
|
||||
if snapshot1[entry_id] != snapshot2[entry_id]:
|
||||
modified.append({
|
||||
"before": snapshot1[entry_id],
|
||||
"after": snapshot2[entry_id],
|
||||
})
|
||||
|
||||
return {
|
||||
"commit1": commit1.to_dict(),
|
||||
"commit2": commit2.to_dict(),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"modified": modified,
|
||||
}
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, repository: MemoryRepository):
|
||||
self.repository = repository
|
||||
self.memory_service = MemoryService(repository)
|
||||
self.search_service = SearchService(repository)
|
||||
self.commit_service = CommitService(repository)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self.repository.initialize()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.repository.close()
|
||||
0
src/memory_manager/db/__init__.py
Normal file
0
src/memory_manager/db/__init__.py
Normal file
134
src/memory_manager/db/models.py
Normal file
134
src/memory_manager/db/models.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""SQLAlchemy database models for the memory manager."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, Index, String, Text, func, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class MemoryCategory(enum.StrEnum):
|
||||
DECISION = "decision"
|
||||
FEATURE = "feature"
|
||||
REFACTORING = "refactoring"
|
||||
ARCHITECTURE = "architecture"
|
||||
BUG = "bug"
|
||||
NOTE = "note"
|
||||
|
||||
|
||||
class MemoryEntry(Base):
|
||||
__tablename__ = "memory_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[MemoryCategory] = mapped_column(Enum(MemoryCategory), nullable=False)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
agent_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
project_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_category", "category"),
|
||||
Index("idx_agent_id", "agent_id"),
|
||||
Index("idx_project_path", "project_path"),
|
||||
Index("idx_created_at", "created_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content": self.content,
|
||||
"category": self.category.value,
|
||||
"tags": self.tags,
|
||||
"agent_id": self.agent_id,
|
||||
"project_path": self.project_path,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Commit(Base):
|
||||
__tablename__ = "commits"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
hash: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
project_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_commit_hash", "hash"),
|
||||
Index("idx_commit_agent_id", "agent_id"),
|
||||
Index("idx_commit_created_at", "created_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"hash": self.hash,
|
||||
"message": self.message,
|
||||
"agent_id": self.agent_id,
|
||||
"project_path": self.project_path,
|
||||
"snapshot": self.snapshot,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def init_db(db_path: str):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await conn.execute(text(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS memory_entries_fts USING fts5("
|
||||
"title, content, tags, category, agent_id, project_path, content='memory_entries', content_rowid='id')"
|
||||
))
|
||||
|
||||
await conn.execute(text(
|
||||
"CREATE TRIGGER IF NOT EXISTS memory_entries_ai AFTER INSERT ON memory_entries BEGIN "
|
||||
"INSERT INTO memory_entries_fts(rowid, title, content, tags, category, agent_id, project_path) "
|
||||
"VALUES (new.id, new.title, new.content, new.tags, new.category, new.agent_id, new.project_path); END"
|
||||
))
|
||||
|
||||
await conn.execute(text(
|
||||
"CREATE TRIGGER IF NOT EXISTS memory_entries_ad AFTER DELETE ON memory_entries BEGIN "
|
||||
"INSERT INTO memory_entries_fts(memory_entries_fts, rowid, title, content, tags, category, agent_id, project_path) "
|
||||
"VALUES ('delete', old.id, old.title, old.content, old.tags, old.category, old.agent_id, old.project_path); END"
|
||||
))
|
||||
|
||||
await conn.execute(text(
|
||||
"CREATE TRIGGER IF NOT EXISTS memory_entries_au AFTER UPDATE ON memory_entries BEGIN "
|
||||
"INSERT INTO memory_entries_fts(memory_entries_fts, rowid, title, content, tags, category, agent_id, project_path) "
|
||||
"VALUES ('delete', old.id, old.title, old.content, old.tags, old.category, old.agent_id, old.project_path); "
|
||||
"INSERT INTO memory_entries_fts(rowid, title, content, tags, category, agent_id, project_path) "
|
||||
"VALUES (new.id, new.title, new.content, new.tags, new.category, new.agent_id, new.project_path); END"
|
||||
))
|
||||
|
||||
return engine
|
||||
232
src/memory_manager/db/repository.py
Normal file
232
src/memory_manager/db/repository.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Async repository for database operations."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from memory_manager.db.models import Commit, MemoryCategory, MemoryEntry, init_db
|
||||
|
||||
|
||||
class MemoryRepository:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
self.engine: Any = None
|
||||
self._session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
db_dir = os.path.dirname(self.db_path)
|
||||
if db_dir and not os.path.exists(db_dir):
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
self.engine = await init_db(self.db_path)
|
||||
self._session_factory = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
async def get_session(self) -> AsyncSession:
|
||||
if not self._session_factory:
|
||||
await self.initialize()
|
||||
assert self._session_factory is not None
|
||||
return self._session_factory()
|
||||
|
||||
async def create_entry(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
category: MemoryCategory,
|
||||
tags: list[str],
|
||||
agent_id: str,
|
||||
project_path: str,
|
||||
) -> MemoryEntry:
|
||||
async with await self.get_session() as session:
|
||||
entry = MemoryEntry(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
tags=tags,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
)
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
async def get_entry(self, entry_id: int) -> MemoryEntry | None:
|
||||
async with await self.get_session() as session:
|
||||
result = await session.execute(select(MemoryEntry).where(MemoryEntry.id == entry_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: MemoryCategory | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> MemoryEntry | None:
|
||||
async with await self.get_session() as session:
|
||||
result = await session.execute(select(MemoryEntry).where(MemoryEntry.id == entry_id))
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
if title is not None:
|
||||
entry.title = title
|
||||
if content is not None:
|
||||
entry.content = content
|
||||
if category is not None:
|
||||
entry.category = category
|
||||
if tags is not None:
|
||||
entry.tags = tags
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
async def delete_entry(self, entry_id: int) -> bool:
|
||||
async with await self.get_session() as session:
|
||||
result = await session.execute(delete(MemoryEntry).where(MemoryEntry.id == entry_id))
|
||||
await session.commit()
|
||||
rowcount = getattr(result, "rowcount", 0)
|
||||
return rowcount is not None and rowcount > 0
|
||||
|
||||
async def list_entries(
|
||||
self,
|
||||
category: MemoryCategory | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[MemoryEntry]:
|
||||
async with await self.get_session() as session:
|
||||
query = select(MemoryEntry)
|
||||
|
||||
if category:
|
||||
query = query.where(MemoryEntry.category == category)
|
||||
if agent_id:
|
||||
query = query.where(MemoryEntry.agent_id == agent_id)
|
||||
if project_path:
|
||||
query = query.where(MemoryEntry.project_path == project_path)
|
||||
|
||||
query = query.order_by(MemoryEntry.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def search_entries(
|
||||
self,
|
||||
query_text: str,
|
||||
category: MemoryCategory | None = None,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[MemoryEntry]:
|
||||
async with await self.get_session() as session:
|
||||
fts_query = f'"{query_text}"'
|
||||
|
||||
sql_parts = ["""
|
||||
SELECT m.* FROM memory_entries m
|
||||
INNER JOIN memory_entries_fts fts ON m.id = fts.rowid
|
||||
WHERE memory_entries_fts MATCH :query
|
||||
"""]
|
||||
|
||||
params: dict[str, Any] = {"query": fts_query}
|
||||
|
||||
if category:
|
||||
sql_parts.append(" AND m.category = :category")
|
||||
params["category"] = category.value
|
||||
if agent_id:
|
||||
sql_parts.append(" AND m.agent_id = :agent_id")
|
||||
params["agent_id"] = agent_id
|
||||
if project_path:
|
||||
sql_parts.append(" AND m.project_path = :project_path")
|
||||
params["project_path"] = project_path
|
||||
|
||||
sql_parts.append(" LIMIT :limit")
|
||||
params["limit"] = limit
|
||||
|
||||
sql = text("".join(sql_parts))
|
||||
|
||||
result = await session.execute(sql, params)
|
||||
rows = result.fetchall()
|
||||
|
||||
entries = []
|
||||
for row in rows:
|
||||
entry = MemoryEntry(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
content=row.content,
|
||||
category=MemoryCategory(row.category),
|
||||
tags=row.tags,
|
||||
agent_id=row.agent_id,
|
||||
project_path=row.project_path,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
async def create_commit(
|
||||
self,
|
||||
hash: str,
|
||||
message: str,
|
||||
agent_id: str,
|
||||
project_path: str,
|
||||
snapshot: list[dict[str, Any]],
|
||||
) -> Commit:
|
||||
async with await self.get_session() as session:
|
||||
commit = Commit(
|
||||
hash=hash,
|
||||
message=message,
|
||||
agent_id=agent_id,
|
||||
project_path=project_path,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
session.add(commit)
|
||||
await session.commit()
|
||||
await session.refresh(commit)
|
||||
return commit
|
||||
|
||||
async def get_commit(self, hash: str) -> Commit | None:
|
||||
async with await self.get_session() as session:
|
||||
result = await session.execute(select(Commit).where(Commit.hash == hash))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_commit_by_id(self, commit_id: int) -> Commit | None:
|
||||
async with await self.get_session() as session:
|
||||
result = await session.execute(select(Commit).where(Commit.id == commit_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_commits(
|
||||
self,
|
||||
agent_id: str | None = None,
|
||||
project_path: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Commit]:
|
||||
async with await self.get_session() as session:
|
||||
query = select(Commit)
|
||||
|
||||
if agent_id:
|
||||
query = query.where(Commit.agent_id == agent_id)
|
||||
if project_path:
|
||||
query = query.where(Commit.project_path == project_path)
|
||||
|
||||
query = query.order_by(Commit.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_entries_snapshot(self, project_path: str | None = None) -> list[dict[str, Any]]:
|
||||
async with await self.get_session() as session:
|
||||
query = select(MemoryEntry)
|
||||
if project_path:
|
||||
query = query.where(MemoryEntry.project_path == project_path)
|
||||
|
||||
result = await session.execute(query)
|
||||
entries = result.scalars().all()
|
||||
return [entry.to_dict() for entry in entries]
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.engine:
|
||||
await self.engine.dispose()
|
||||
0
src/memory_manager/tui/__init__.py
Normal file
0
src/memory_manager/tui/__init__.py
Normal file
363
src/memory_manager/tui/app.py
Normal file
363
src/memory_manager/tui/app.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Textual TUI application for the memory manager."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
Label,
|
||||
ListItem,
|
||||
ListView,
|
||||
Static,
|
||||
)
|
||||
|
||||
from memory_manager.core.services import MemoryManager
|
||||
from memory_manager.db.models import MemoryCategory
|
||||
from memory_manager.db.repository import MemoryRepository
|
||||
|
||||
db_path = os.getenv("MEMORY_DB_PATH", ".memory/codebase_memory.db")
|
||||
|
||||
|
||||
async def get_memory_manager() -> MemoryManager:
|
||||
repository = MemoryRepository(db_path)
|
||||
await repository.initialize()
|
||||
manager = MemoryManager(repository)
|
||||
return manager
|
||||
|
||||
|
||||
class DashboardScreen(Screen):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
.stats-container {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $panel;
|
||||
border: solid $border;
|
||||
}
|
||||
.stat-label {
|
||||
color: $text-muted;
|
||||
}
|
||||
.stat-value {
|
||||
color: $text;
|
||||
bold: true;
|
||||
}
|
||||
.entry-list {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, manager: MemoryManager):
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Container(
|
||||
Vertical(
|
||||
Label("Memory Manager Dashboard", classes="header-title"),
|
||||
ScrollableContainer(classes="stats-container", id="stats-container"),
|
||||
ListView(id="recent-entries", classes="entry-list"),
|
||||
id="dashboard-content",
|
||||
)
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await self.load_stats()
|
||||
|
||||
async def load_stats(self) -> None:
|
||||
stats_container = self.query_one("#stats-container", ScrollableContainer)
|
||||
|
||||
entries = await self.manager.memory_service.list_entries(limit=10000)
|
||||
commits = await self.manager.commit_service.list_commits(limit=10000)
|
||||
|
||||
entries_by_category: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
cat = entry["category"]
|
||||
entries_by_category[cat] = entries_by_category.get(cat, 0) + 1
|
||||
|
||||
stats_text = f"""
|
||||
Total Entries: {len(entries)} | Total Commits: {len(commits)}
|
||||
|
||||
Entries by Category:
|
||||
"""
|
||||
for cat, count in entries_by_category.items():
|
||||
stats_text += f" {cat}: {count}\n"
|
||||
|
||||
stats_container.remove_children()
|
||||
stats_container.mount(Static(stats_text))
|
||||
|
||||
list_view = self.query_one("#recent-entries", ListView)
|
||||
list_view.clear()
|
||||
for entry in entries[:10]:
|
||||
created = entry["created_at"]
|
||||
if created:
|
||||
created = datetime.fromisoformat(created).strftime("%m/%d %H:%M")
|
||||
list_item = ListItem(
|
||||
Label(f"[{entry['category']}] {entry['title'][:40]} - {created}"),
|
||||
)
|
||||
await list_view.mount(list_item)
|
||||
|
||||
|
||||
class MemoryListScreen(Screen):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
.filter-bar {
|
||||
height: 3;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
}
|
||||
.entry-detail {
|
||||
height: 1fr;
|
||||
padding: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, manager: MemoryManager, category: MemoryCategory | None = None):
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.current_category = category
|
||||
self.entries: list[dict[str, Any]] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Container(
|
||||
Horizontal(
|
||||
Button("All", id="filter-all"),
|
||||
Button("Decision", id="filter-decision"),
|
||||
Button("Feature", id="filter-feature"),
|
||||
Button("Refactoring", id="filter-refactoring"),
|
||||
Button("Architecture", id="filter-architecture"),
|
||||
Button("Bug", id="filter-bug"),
|
||||
Button("Note", id="filter-note"),
|
||||
classes="filter-bar",
|
||||
),
|
||||
Horizontal(
|
||||
ListView(id="entries-list", classes="column"),
|
||||
ScrollableContainer(id="entry-detail", classes="column entry-detail"),
|
||||
classes="main-content",
|
||||
),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await self.load_entries()
|
||||
|
||||
async def load_entries(self, category: MemoryCategory | None = None) -> None:
|
||||
self.entries = await self.manager.memory_service.list_entries(
|
||||
category=category,
|
||||
limit=1000,
|
||||
)
|
||||
|
||||
list_view = self.query_one("#entries-list", ListView)
|
||||
list_view.clear()
|
||||
|
||||
for entry in self.entries:
|
||||
created = entry["created_at"]
|
||||
if created:
|
||||
created = datetime.fromisoformat(created).strftime("%m/%d %H:%M")
|
||||
list_item = ListItem(
|
||||
Label(f"[{entry['category']}] {entry['title']}"),
|
||||
Label(f"{entry['agent_id']} | {created}"),
|
||||
)
|
||||
await list_view.mount(list_item)
|
||||
|
||||
@on(ListView.Selected)
|
||||
async def on_entry_selected(self, event: ListView.Selected) -> None:
|
||||
index = event.list_view.index
|
||||
if index is not None and 0 <= index < len(self.entries):
|
||||
entry = self.entries[index]
|
||||
detail_container = self.query_one("#entry-detail", ScrollableContainer)
|
||||
detail_container.remove_children()
|
||||
|
||||
content = f"""
|
||||
Title: {entry['title']}
|
||||
Category: {entry['category']}
|
||||
Agent: {entry['agent_id']}
|
||||
Project: {entry['project_path']}
|
||||
Tags: {', '.join(entry['tags']) if entry['tags'] else '(none)'}
|
||||
Created: {entry['created_at']}
|
||||
Updated: {entry['updated_at']}
|
||||
|
||||
Content:
|
||||
{entry['content']}
|
||||
"""
|
||||
await detail_container.mount(Static(content))
|
||||
|
||||
@on(Button.Pressed)
|
||||
async def on_filter_pressed(self, event: Button.Pressed) -> None:
|
||||
button_id = event.button.id
|
||||
category = None
|
||||
|
||||
if button_id == "filter-decision":
|
||||
category = MemoryCategory.DECISION
|
||||
elif button_id == "filter-feature":
|
||||
category = MemoryCategory.FEATURE
|
||||
elif button_id == "filter-refactoring":
|
||||
category = MemoryCategory.REFACTORING
|
||||
elif button_id == "filter-architecture":
|
||||
category = MemoryCategory.ARCHITECTURE
|
||||
elif button_id == "filter-bug":
|
||||
category = MemoryCategory.BUG
|
||||
elif button_id == "filter-note":
|
||||
category = MemoryCategory.NOTE
|
||||
|
||||
await self.load_entries(category)
|
||||
|
||||
|
||||
class CommitHistoryScreen(Screen):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
.commit-list {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, manager: MemoryManager):
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.commits: list[dict[str, Any]] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Container(
|
||||
ScrollableContainer(
|
||||
ListView(id="commits-list", classes="commit-list"),
|
||||
id="commits-container",
|
||||
),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await self.load_commits()
|
||||
|
||||
async def load_commits(self) -> None:
|
||||
self.commits = await self.manager.commit_service.list_commits(limit=100)
|
||||
|
||||
list_view = self.query_one("#commits-list", ListView)
|
||||
list_view.clear()
|
||||
|
||||
for commit in self.commits:
|
||||
created = commit["created_at"]
|
||||
if created:
|
||||
created = datetime.fromisoformat(created).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
content = f"commit {commit['hash'][:8]}\n{commit['agent_id']} | {created}\n\n {commit['message']}"
|
||||
|
||||
list_item = ListItem(
|
||||
Static(content, markup=False),
|
||||
)
|
||||
await list_view.mount(list_item)
|
||||
|
||||
|
||||
class SearchScreen(Screen):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
.search-input {
|
||||
height: 3;
|
||||
padding: 1;
|
||||
}
|
||||
.results-list {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, manager: MemoryManager):
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.results: list[dict[str, Any]] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Container(
|
||||
Horizontal(
|
||||
Input(placeholder="Search query...", id="search-input"),
|
||||
Button("Search", id="search-button"),
|
||||
classes="search-input",
|
||||
),
|
||||
ScrollableContainer(
|
||||
ListView(id="results-list", classes="results-list"),
|
||||
id="results-container",
|
||||
),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
@on(Button.Pressed, "#search-button")
|
||||
@on(Input.Submitted, "#search-input")
|
||||
async def on_search(self) -> None:
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
query = input_widget.value
|
||||
|
||||
if not query:
|
||||
return
|
||||
|
||||
self.results = await self.manager.search_service.search(query=query, limit=100)
|
||||
|
||||
list_view = self.query_one("#results-list", ListView)
|
||||
list_view.clear()
|
||||
|
||||
for entry in self.results:
|
||||
created = entry["created_at"]
|
||||
if created:
|
||||
created = datetime.fromisoformat(created).strftime("%m/%d %H:%M")
|
||||
|
||||
list_item = ListItem(
|
||||
Label(f"[{entry['category']}] {entry['title'][:40]}"),
|
||||
Label(f"{entry['content'][:80]}... | {created}"),
|
||||
)
|
||||
await list_view.mount(list_item)
|
||||
|
||||
|
||||
class TUIApp(App):
|
||||
BINDINGS = [
|
||||
Binding("d", "switch_dashboard", "Dashboard"),
|
||||
Binding("l", "switch_memory_list", "Memory List"),
|
||||
Binding("c", "switch_commits", "Commits"),
|
||||
Binding("s", "switch_search", "Search"),
|
||||
Binding("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.manager = None
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.manager = await get_memory_manager()
|
||||
await self.push_screen(DashboardScreen(self.manager))
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
if self.manager:
|
||||
await self.manager.close()
|
||||
|
||||
async def switch_dashboard(self) -> None:
|
||||
if self.manager:
|
||||
await self.push_screen(DashboardScreen(self.manager))
|
||||
|
||||
async def switch_memory_list(self) -> None:
|
||||
if self.manager:
|
||||
await self.push_screen(MemoryListScreen(self.manager))
|
||||
|
||||
async def switch_commits(self) -> None:
|
||||
if self.manager:
|
||||
await self.push_screen(CommitHistoryScreen(self.manager))
|
||||
|
||||
async def switch_search(self) -> None:
|
||||
if self.manager:
|
||||
await self.push_screen(SearchScreen(self.manager))
|
||||
Reference in New Issue
Block a user