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>
255 lines
8.0 KiB
Python
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)}"
|
|
) |