Ryan Malloy 44ed9936b7 Initial commit: Claude Code Project Tracker
Add comprehensive development intelligence system that tracks:
- Development sessions with automatic start/stop
- Full conversation history with semantic search
- Tool usage and file operation analytics
- Think time and engagement analysis
- Git activity correlation
- Learning pattern recognition
- Productivity insights and metrics

Features:
- FastAPI backend with SQLite database
- Modern web dashboard with interactive charts
- Claude Code hook integration for automatic tracking
- Comprehensive test suite with 100+ tests
- Complete API documentation (OpenAPI/Swagger)
- Privacy-first design with local data storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 02:59:21 -06:00

255 lines
8.0 KiB
Python

"""
Session management API endpoints.
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database.connection import get_db
from app.models.project import Project
from app.models.session import Session
from app.api.schemas import SessionStartRequest, SessionEndRequest, SessionResponse
router = APIRouter()
async def get_or_create_project(
db: AsyncSession,
working_directory: str,
git_repo: Optional[str] = None
) -> Project:
"""Get existing project or create a new one based on working directory."""
# Try to find existing project by path
result = await db.execute(
select(Project).where(Project.path == working_directory)
)
project = result.scalars().first()
if project:
return project
# Create new project
project_name = os.path.basename(working_directory) or "Unknown Project"
# Try to infer languages from directory (simple heuristic)
languages = []
try:
for root, dirs, files in os.walk(working_directory):
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext == ".py":
languages.append("python")
elif ext in [".js", ".jsx"]:
languages.append("javascript")
elif ext in [".ts", ".tsx"]:
languages.append("typescript")
elif ext == ".go":
languages.append("go")
elif ext == ".rs":
languages.append("rust")
elif ext == ".java":
languages.append("java")
elif ext in [".cpp", ".cc", ".cxx"]:
languages.append("cpp")
elif ext == ".c":
languages.append("c")
# Don't traverse too deep
if len(root.replace(working_directory, "").split(os.sep)) > 2:
break
languages = list(set(languages))[:5] # Keep unique, limit to 5
except (OSError, PermissionError):
# If we can't read the directory, that's okay
pass
project = Project(
name=project_name,
path=working_directory,
git_repo=git_repo,
languages=languages if languages else None
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.post("/session/start", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
request: SessionStartRequest,
db: AsyncSession = Depends(get_db)
):
"""
Start a new development session.
This endpoint is called by Claude Code hooks when a session begins.
It will create a project if one doesn't exist for the working directory.
"""
try:
# Get or create project
project = await get_or_create_project(
db=db,
working_directory=request.working_directory,
git_repo=request.git_repo
)
# Create new session
session = Session(
project_id=project.id,
session_type=request.session_type,
working_directory=request.working_directory,
git_branch=request.git_branch,
environment=request.environment
)
db.add(session)
await db.commit()
await db.refresh(session)
# Store session ID in temp file for hooks to use
session_file = "/tmp/claude-session-id"
try:
with open(session_file, "w") as f:
f.write(str(session.id))
except OSError:
# If we can't write the session file, log but don't fail
pass
return SessionResponse(
session_id=session.id,
project_id=project.id,
status="started",
message=f"Session started for project '{project.name}'"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start session: {str(e)}"
)
@router.post("/session/end", response_model=SessionResponse)
async def end_session(
request: SessionEndRequest,
db: AsyncSession = Depends(get_db)
):
"""
End an active development session.
This endpoint calculates final session statistics and updates
project-level metrics.
"""
try:
# Find the session
result = await db.execute(
select(Session)
.options(selectinload(Session.project))
.where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
if not session.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already ended"
)
# End the session
session.end_session(end_reason=request.end_reason)
await db.commit()
# Clean up session ID file
session_file = "/tmp/claude-session-id"
try:
if os.path.exists(session_file):
os.remove(session_file)
except OSError:
pass
return SessionResponse(
session_id=session.id,
project_id=session.project_id,
status="ended",
message=f"Session ended after {session.duration_minutes} minutes"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to end session: {str(e)}"
)
@router.get("/sessions/{session_id}", response_model=dict)
async def get_session(
session_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific session."""
try:
result = await db.execute(
select(Session)
.options(
selectinload(Session.project),
selectinload(Session.conversations),
selectinload(Session.activities),
selectinload(Session.waiting_periods),
selectinload(Session.git_operations)
)
.where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
return {
"id": session.id,
"project": {
"id": session.project.id,
"name": session.project.name,
"path": session.project.path
},
"start_time": session.start_time,
"end_time": session.end_time,
"duration_minutes": session.calculated_duration_minutes,
"session_type": session.session_type,
"git_branch": session.git_branch,
"activity_count": session.activity_count,
"conversation_count": session.conversation_count,
"is_active": session.is_active,
"statistics": {
"conversations": len(session.conversations),
"activities": len(session.activities),
"waiting_periods": len(session.waiting_periods),
"git_operations": len(session.git_operations),
"files_touched": len(session.files_touched or [])
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get session: {str(e)}"
)