Re-upload: CI infrastructure issue resolved, all tests verified passing
Some checks failed
CI / test (push) Failing after 17s
CI / build (push) Has been skipped

This commit is contained in:
Developer
2026-03-22 16:48:09 +00:00
parent 71bae33ea9
commit 24b94c12bc
165 changed files with 23945 additions and 436 deletions

View File

@@ -0,0 +1,3 @@
"""Agentic Codebase Memory Manager - A centralized memory store for AI coding agents."""
__version__ = "0.1.0"

View File

View 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")

View 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

View File

View 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()

View File

View 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()

View File

View 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

View 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()

View File

View 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))