""" V4 Chat API routes. Session lifecycle: POST /api/v4/chat/spaces/{repo_id}/sessions – create session (seeds context from head commit) GET /api/v4/chat/spaces/{repo_id}/sessions/{sid} – get session GET /api/v4/chat/spaces/{repo_id}/sessions/{sid}/turns – list turns Conversation: POST /api/v4/chat/send – send a user message, get assistant reply POST /api/v4/chat/commit – manually commit session state as a Commit Space head: GET /api/v4/chat/spaces/{repo_id}/head – latest commit + latest session Provider status: GET /api/v4/chat/providers – list provider config status """ from __future__ import annotations import hashlib import json import logging import uuid from datetime import datetime, timezone from typing import Optional logger = logging.getLogger(__name__) from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from app.db.database import get_db from app.db.models import ( ChatSession, CommitModel, RepoModel, TurnEvent, WorkClaim, WorkTree, ) from app.config_loader import get_config, providers_status, ProviderNotConfiguredError from app.providers.registry import get_adapter, get_mock_adapter from app.services.worktree_probe import _probe_worktree router = APIRouter(prefix="/chat", tags=["chat-v4"]) DEMO_USER_ID = uuid.UUID("00000000-0000-0000-0000-00000000000a") MAX_CONTEXT_TURNS = 20 # how many recent turns to pass as conversation history # ── Helpers ─────────────────────────────────────────────────────────────────── def _utcnow() -> datetime: return datetime.now(timezone.utc) def _get_repo(repo_id: uuid.UUID, db: Session) -> RepoModel: repo = db.get(RepoModel, repo_id) if repo_id else None if not repo or repo.user_id != DEMO_USER_ID: raise HTTPException(status_code=404, detail="Space not found") return repo def _get_latest_commit(repo_id: uuid.UUID, db: Session) -> CommitModel | None: stmt = ( select(CommitModel) .where(CommitModel.repo_id == repo_id, CommitModel.branch_name == "main") .order_by(CommitModel.created_at.desc()) .limit(1) ) return db.scalars(stmt).first() def _get_latest_commit_on_branch(repo_id: uuid.UUID, branch: str, db: Session) -> CommitModel | None: """Return the most recent commit on a specific branch within a repo.""" stmt = ( select(CommitModel) .where(CommitModel.repo_id == repo_id, CommitModel.branch_name == branch) .order_by(CommitModel.created_at.desc()) .limit(1) ) return db.scalars(stmt).first() def _get_checkpoints_for_scope(repo_id: uuid.UUID, db: Session, scope: str) -> list[CommitModel]: """Return checkpoints based on memory scope (oldest-first).""" n = 3 if scope == "latest_3" else 1 stmt = ( select(CommitModel) .where(CommitModel.repo_id == repo_id, CommitModel.branch_name == "main") .order_by(CommitModel.created_at.desc()) .limit(n) ) return list(reversed(db.scalars(stmt).all())) # oldest first def _walk_ancestors(commit: CommitModel, db: Session, n: int) -> list[CommitModel]: """Walk parent chain and return up to n checkpoints, oldest-first.""" chain = [commit] current = commit for _ in range(n - 1): if not current.parent_commit_id: break parent = db.get(CommitModel, current.parent_commit_id) if not parent: break chain.append(parent) current = parent return list(reversed(chain)) # oldest first def _resolve_checkpoints( repo_id: Optional[uuid.UUID], mounted_checkpoint_id: Optional[str], scope: str, db: Session, ) -> list[CommitModel]: """ Resolve which checkpoints to mount for context building. Priority: 1. If mounted_checkpoint_id is set, use that specific checkpoint as anchor. - scope latest_1 → only that checkpoint - scope latest_3 → that checkpoint + up to 2 ancestors 2. Otherwise fall back to _get_checkpoints_for_scope (latest N from repo head). """ if mounted_checkpoint_id: try: commit = db.get(CommitModel, uuid.UUID(mounted_checkpoint_id)) except (ValueError, Exception): commit = None if commit: n = 3 if scope == "latest_3" else 1 return _walk_ancestors(commit, db, n) if repo_id: return _get_checkpoints_for_scope(repo_id, db, scope) return [] def build_prompt_from_checkpoints(checkpoints: list[CommitModel], recent_messages: list[TurnEvent], user_input: str) -> str: """Reconstructs the conversation context from Smriti memory (supports multiple checkpoints).""" lines = ["You are continuing a conversation.\n"] for i, ckpt in enumerate(checkpoints): label = f"Checkpoint {i + 1}" if len(checkpoints) > 1 else "Checkpoint" if ckpt.summary: lines.append(f"{label} Summary:") lines.append(ckpt.summary + "\n") if ckpt.decisions: lines.append(f"{label} Key Decisions:") for d in ckpt.decisions: lines.append(f"- {d}") lines.append("") if ckpt.assumptions: lines.append(f"{label} Key Assumptions:") for a in ckpt.assumptions: lines.append(f"- {a}") lines.append("") if ckpt.tasks: lines.append(f"{label} Open Tasks:") for t in ckpt.tasks: lines.append(f"- {t}") lines.append("") if ckpt.artifacts: lines.append(f"{label} Attached Artifacts:") for art in ckpt.artifacts: art_label = art.get('label', 'Untitled') if isinstance(art, dict) else 'Untitled' art_content = art.get('content', '') if isinstance(art, dict) else str(art) # Cap each artifact at 2000 chars to manage prompt size if len(art_content) > 2000: art_content = art_content[:2000] + "\n[… truncated]" lines.append(f"\n[{art_label}]:") lines.append(art_content) lines.append("") if recent_messages: lines.append("Recent Conversation:") for m in recent_messages: role = "User" if m.role == "user" else "Assistant" lines.append(f"{role}: {m.content}") lines.append("") lines.append("User Query:") lines.append(user_input) return "\n".join(lines) # Keep single-checkpoint variant for backwards compatibility (used internally) def build_prompt_from_checkpoint(checkpoint: CommitModel | None, recent_messages: list[TurnEvent], user_input: str) -> str: checkpoints = [checkpoint] if checkpoint else [] return build_prompt_from_checkpoints(checkpoints, recent_messages, user_input) def _generate_commit_hash(repo_id: str, message: str) -> str: content = {"repo_id": repo_id, "message": message, "ts": _utcnow().isoformat()} return hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest() # ── Response/Request schemas ────────────────────────────────────────────────── class SessionResponse(BaseModel): id: uuid.UUID repo_id: Optional[uuid.UUID] title: str active_provider: str active_model: str seeded_commit_id: Optional[uuid.UUID] forked_from_checkpoint_id: Optional[uuid.UUID] branch_name: str created_at: datetime updated_at: datetime # Set only by the /sessions/{id}/title endpoint. Stable enum-like values: # "ok" — title was generated and saved # "skipped:provider_not_configured" — no background LLM provider configured # "skipped:provider_error" — provider call failed (network/4xx/etc.) # "skipped:db_error" — DB write failed after generation # All other endpoints return SessionResponse with this field unset (null), # so clients that don't know about it are unaffected. title_generation_status: Optional[str] = None model_config = {"from_attributes": True} class TurnResponse(BaseModel): id: uuid.UUID session_id: uuid.UUID role: str content: str provider: str model: str sequence_number: int created_at: datetime model_config = {"from_attributes": True} class CreateSessionRequest(BaseModel): repo_id: Optional[str] = None title: str = "" provider: str = "" model: str = "" seed_from: str = "head" # "head" | "none" | "" class SendMessageRequest(BaseModel): session_id: str repo_id: Optional[str] = None provider: str model: str message: str use_mock: bool = Field( False, description="If true, use the deterministic mock adapter (no API key required)" ) memory_scope: str = Field( "latest_1", description="Memory scope for context: 'latest_1' or 'latest_3'" ) mounted_checkpoint_id: Optional[str] = Field( None, description="Explicit checkpoint id to anchor context. If set, overrides latest-head selection." ) history_base_seq: Optional[int] = Field( None, description="When mounting a specific commit, the sequence number of the last turn before mounting. Only turns with sequence_number > history_base_seq are included as history, preventing pre-mount turns from bleeding into the mounted context." ) class SendMessageResponse(BaseModel): reply: str session_id: uuid.UUID turn_count: int provider: str model: str class ManualCommitRequest(BaseModel): repo_id: str session_id: str message: str summary: str = "" objective: str = "" decisions: list[str] = Field(default_factory=list) assumptions: list[str] = Field(default_factory=list) tasks: list = Field(default_factory=list) # str or structured task objects open_questions: list[str] = Field(default_factory=list) entities: list[str] = Field(default_factory=list) artifacts: list[dict] = Field(default_factory=list) author_agent: Optional[str] = None project_root: Optional[str] = None # Git provenance the CLI captures at checkpoint time: {head, head_short, # branch}. Lets repo-state drift detection compare the working repo # against where it was when this checkpoint was recorded. Empty for # clients that cannot inspect a repo (e.g. the MCP server). repo_state: dict = Field(default_factory=dict) class CommitResponse(BaseModel): id: uuid.UUID repo_id: uuid.UUID commit_hash: str parent_commit_id: Optional[uuid.UUID] branch_name: str author_agent: Optional[str] = None project_root: Optional[str] = None message: str summary: str objective: str decisions: list assumptions: list tasks: list open_questions: list entities: list artifacts: list context_blob: dict = Field(default_factory=dict) created_at: datetime model_config = {"from_attributes": True} class HeadResponse(BaseModel): repo_id: uuid.UUID commit_hash: Optional[str] commit_id: Optional[uuid.UUID] summary: Optional[str] objective: Optional[str] latest_session_id: Optional[uuid.UUID] latest_session_title: Optional[str] # ── Space state (multi-branch) schemas ──────────────────────────────────────── # # `GET /api/v4/chat/spaces/{id}/state` is the richer sibling of `get_head`. # It returns the main-branch continuation brief the chat UI / CLI already # knew how to render, plus a concise per-branch summary of non-main activity # and a lightweight divergence signal when any active branch disagrees with # main on decisions. Both extensions are capped hard so the aggregate output # stays digestible for an agent reading it into its context window. class SpaceBrief(BaseModel): """Minimal space header for the state response. Mirrors what the CLI formatter already consumes from its `space` arg.""" id: uuid.UUID name: str description: Optional[str] = None project_root: Optional[str] = None class ActiveBranchSummary(BaseModel): """One line per non-main branch in the `Active branches` section. Kept minimal on purpose — this is not a brief, it is a pointer.""" branch_name: str commit_id: uuid.UUID commit_hash: str message: str author_agent: Optional[str] = None created_at: datetime # First ~200 chars of the commit's summary. Callers use this as a # one-liner in the Active branches section, not a full read. summary: str = "" class DivergencePair(BaseModel): """Per-branch divergence entry. `main_only_decisions` are decisions present on main but absent from this branch; `branch_only_decisions` are the reverse. Both arrays capped at DIVERGENCE_DECISIONS_CAP items by the endpoint so the state response cannot blow up on active projects.""" branch_name: str branch_commit_hash: str main_only_decisions: list[str] = Field(default_factory=list) branch_only_decisions: list[str] = Field(default_factory=list) class DivergenceSummary(BaseModel): """Top-level divergence block. Only populated when at least one active branch differs from main on decisions. Capped at DIVERGENCE_BRANCHES_CAP pairs.""" pairs: list[DivergencePair] = Field(default_factory=list) class ActiveWorktreeSummary(BaseModel): """Git drift information for a worktree bound to an active claim.""" id: uuid.UUID path: str branch: str dirty_files: int dirty_paths: list[str] = Field(default_factory=list) ahead: int behind: int last_commit_sha: str last_commit_relative: str class ActiveClaimSummary(BaseModel): """One line per active work claim in the `Active work` section.""" id: uuid.UUID agent: str branch_name: str scope: str task_id: Optional[str] = None worktree_id: Optional[uuid.UUID] = None worktree: Optional[ActiveWorktreeSummary] = None intent_type: str claimed_at: datetime expires_at: datetime base_commit_hash: Optional[str] = None model_config = {"from_attributes": True} class FreshnessCheckpoint(BaseModel): """One entry in the freshness 'new checkpoints' list.""" commit_hash: str author_agent: Optional[str] = None message: str created_at: datetime class FreshnessInfo(BaseModel): """Pull-time freshness signal. Included in SpaceStateResponse only when the caller provides since_commit_id.""" changed: bool since_commit_hash: str current_head_hash: str new_checkpoints_count: int = 0 new_checkpoints: list[FreshnessCheckpoint] = Field(default_factory=list) class SpaceStateResponse(BaseModel): """Composite response for `GET /spaces/{id}/state`. One round trip, atomic snapshot. Replaces the CLI's previous 3-call dance (resolve_space → get_head → get_commit) for the state brief. """ space: SpaceBrief head: HeadResponse commit: Optional[CommitResponse] = None # None when space has no checkpoints active_branches: list[ActiveBranchSummary] = Field(default_factory=list) active_claims: list[ActiveClaimSummary] = Field(default_factory=list) divergence: Optional[DivergenceSummary] = None freshness: Optional[FreshnessInfo] = None # Hard caps for the state response. These are constants on purpose — # not query params — because the agent-facing contract is "digestible by # default." Callers who need an unbounded view should hit the lineage # endpoint (/api/v5/lineage/spaces/{id}) which is already that. ACTIVE_BRANCHES_CAP = 5 DIVERGENCE_BRANCHES_CAP = 2 DIVERGENCE_DECISIONS_CAP = 3 # ── Session endpoints ───────────────────────────────────────────────────────── @router.post("/sessions", response_model=SessionResponse, status_code=201) def create_session_generic(payload: CreateSessionRequest, db: Session = Depends(get_db)): repo_id = uuid.UUID(payload.repo_id) if payload.repo_id else None if repo_id: _get_repo(repo_id, db) seeded_commit_id = None if repo_id and payload.seed_from == "head": latest = _get_latest_commit(repo_id, db) if latest: seeded_commit_id = latest.id elif payload.seed_from not in ("none", "head", ""): try: c = db.get(CommitModel, uuid.UUID(payload.seed_from)) if c and c.repo_id == repo_id: seeded_commit_id = c.id except (ValueError, Exception): pass cfg = get_config() provider = payload.provider or cfg.chat.default_provider title = payload.title or f"Session {_utcnow().strftime('%b %d %H:%M')}" session = ChatSession( repo_id=repo_id, title=title, active_provider=provider, active_model=payload.model, seeded_commit_id=seeded_commit_id, ) db.add(session) db.commit() db.refresh(session) return session @router.get("/sessions", response_model=list[SessionResponse]) def list_recent_sessions(db: Session = Depends(get_db)): stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(50) return db.scalars(stmt).all() @router.get("/sessions/{session_id}", response_model=SessionResponse) def get_session_generic(session_id: uuid.UUID, db: Session = Depends(get_db)): session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") return session @router.post("/sessions/{session_id}/title", response_model=SessionResponse) def generate_session_title(session_id: uuid.UUID, db: Session = Depends(get_db)): """Generate a meaningful title for a session using the background intelligence model. Always returns 200 with the session — title generation is best-effort and must never block the chat surface. Failure modes are surfaced via `title_generation_status` (stable enum) and a single WARNING/ERROR log line with diagnostic context (session id, provider, model, exception type/message). Pre this fix, failures were swallowed silently by a bare `except Exception: pass`, so a misconfigured provider was indistinguishable from "title not requested yet." """ session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") turns = db.scalars( select(TurnEvent) .where(TurnEvent.session_id == session_id) .order_by(TurnEvent.sequence_number) .limit(4) ).all() if not turns: raise HTTPException(status_code=400, detail="No turns to generate title from") transcript = "\n".join(f"{t.role.upper()}: {t.content[:300]}" for t in turns) prompt = ( "Generate a concise 3–5 word title for this conversation. " "Output ONLY the title, no quotes, no punctuation, no explanation.\n\n" f"{transcript}\n\nTitle:" ) cfg = get_config() bg_provider = cfg.background.provider bg_model = cfg.background.model # Resolve adapter. Provider-not-configured is the most common failure path # — make it visible (WARNING + status), never silent. Returns the session # unchanged so the chat surface is not blocked. try: adapter = get_adapter(bg_provider, allow_mock=False) except ProviderNotConfiguredError as e: logger.warning( "session_title: skipped — provider not configured " "(session_id=%s provider=%s model=%s exc=%s: %s)", session_id, bg_provider, bg_model, type(e).__name__, e, ) response = SessionResponse.model_validate(session) response.title_generation_status = "skipped:provider_not_configured" return response # Provider call. Network errors, 4xx/5xx from the LLM, JSON parse, etc. # Logged at WARNING — the chat surface is not blocked. We log the # exception type and str(e); provider SDK errors do not place the API # key in str(e), so no secret leakage. session_id, provider, model give # enough context to diagnose without dumping the raw request. try: raw_title = adapter.send([{"role": "user", "content": prompt}], model=bg_model).strip() except Exception as e: logger.warning( "session_title: skipped — provider call failed " "(session_id=%s provider=%s model=%s exc=%s: %s)", session_id, bg_provider, bg_model, type(e).__name__, e, ) response = SessionResponse.model_validate(session) response.title_generation_status = "skipped:provider_error" return response title = raw_title.strip("\"'").strip() if len(title) > 60: title = title[:60] session.title = title session.updated_at = _utcnow() # DB write. A failure here is a real bug (LLM call already succeeded), # so log at ERROR. Still return 200 with the unchanged in-memory session # so the chat surface is not blocked. The next call will retry. try: db.commit() db.refresh(session) except Exception as e: db.rollback() logger.error( "session_title: skipped — db write failed after successful generation " "(session_id=%s provider=%s model=%s exc=%s: %s)", session_id, bg_provider, bg_model, type(e).__name__, e, ) response = SessionResponse.model_validate(session) response.title_generation_status = "skipped:db_error" return response response = SessionResponse.model_validate(session) response.title_generation_status = "ok" return response @router.get("/sessions/{session_id}/turns", response_model=list[TurnResponse]) def list_turns_generic(session_id: uuid.UUID, db: Session = Depends(get_db)): session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") stmt = select(TurnEvent).where(TurnEvent.session_id == session_id).order_by(TurnEvent.sequence_number) return db.scalars(stmt).all() @router.delete("/sessions/{session_id}", status_code=204) def delete_session_generic(session_id: uuid.UUID, db: Session = Depends(get_db)) -> Response: """Delete a chat session and cascade to its turn events. Commits authored by this session are preserved (they are owned by the space, not the session).""" session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") if session.repo_id is not None: repo = db.get(RepoModel, session.repo_id) if not repo or repo.user_id != DEMO_USER_ID: raise HTTPException(status_code=404, detail="Session not found") db.delete(session) db.commit() return Response(status_code=204) class AttachSessionRequest(BaseModel): repo_id: str @router.put("/sessions/{session_id}/attach", response_model=SessionResponse) def attach_session(session_id: uuid.UUID, payload: AttachSessionRequest, db: Session = Depends(get_db)): session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") repo_id = uuid.UUID(payload.repo_id) _get_repo(repo_id, db) session.repo_id = repo_id # Update turns to match new namespace from sqlalchemy import update db.execute(update(TurnEvent).where(TurnEvent.session_id == session_id).values(repo_id=repo_id)) db.commit() db.refresh(session) return session # Legacy detail routes for debug views @router.post("/spaces/{repo_id}/sessions", response_model=SessionResponse, status_code=201) def create_session( repo_id: uuid.UUID, payload: CreateSessionRequest, db: Session = Depends(get_db), ): payload.repo_id = str(repo_id) return create_session_generic(payload, db) @router.get("/spaces/{repo_id}/sessions/{session_id}", response_model=SessionResponse) def get_session( repo_id: uuid.UUID, session_id: uuid.UUID, db: Session = Depends(get_db), ): return get_session_generic(session_id, db) @router.get("/spaces/{repo_id}/sessions/{session_id}/turns", response_model=list[TurnResponse]) def list_turns( repo_id: uuid.UUID, session_id: uuid.UUID, db: Session = Depends(get_db), ): return list_turns_generic(session_id, db) # ── Send endpoint ───────────────────────────────────────────────────────────── @router.post("/send", response_model=SendMessageResponse) def send_message(payload: SendMessageRequest, db: Session = Depends(get_db)): """ Send a user message and receive an assistant reply. Context strategy: - On the FIRST user turn (sequence_number == 0): if the session has a seeded_commit_id, inject a system message built from that commit snapshot. - On subsequent turns: pass only the recent session turns as history. - Provider switching: handled naturally — the new provider receives all prior turns as conversation history (no extra re-injection). """ session_id = uuid.UUID(payload.session_id) session = db.get(ChatSession, session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") repo_id = session.repo_id if not payload.model: raise HTTPException(status_code=400, detail="Model must be specified") # Determine next sequence number stmt = ( select(TurnEvent) .where(TurnEvent.session_id == session_id) .order_by(TurnEvent.sequence_number.desc()) .limit(1) ) last_turn = db.scalars(stmt).first() next_seq = (last_turn.sequence_number + 1) if last_turn else 0 # Determine effective checkpoint anchor. # # Priority order: # 1. Explicit mounted_checkpoint_id from request (user is temporarily mounting a # specific checkpoint in an existing session — the isolation boundary is # history_base_seq supplied by the frontend at mount time). # 2. session.forked_from_checkpoint_id (permanent fork — the session branched from # this checkpoint and has no inherited live turns; history_base_seq is 0). # 3. Scope-based HEAD resolution (normal HEAD mode). effective_checkpoint_id = payload.mounted_checkpoint_id effective_base_seq: Optional[int] = payload.history_base_seq is_isolated = False if effective_checkpoint_id is not None and effective_base_seq is not None: # Case 1: explicit temporary mount is_isolated = True elif effective_checkpoint_id is None and session.forked_from_checkpoint_id is not None: # Case 2: forked session — auto-inherit, treat start-of-session as boundary effective_checkpoint_id = str(session.forked_from_checkpoint_id) effective_base_seq = 0 is_isolated = True checkpoints = _resolve_checkpoints(repo_id, effective_checkpoint_id, payload.memory_scope, db) latest_checkpoint = checkpoints[-1] if checkpoints else None # Get recent messages using the appropriate isolation boundary. # # Isolated mode (explicit mount OR forked session): only turns with # sequence_number > effective_base_seq are included, so pre-fork / pre-mount # turns from other branches cannot bleed in. # # HEAD mode: turns since the latest checkpoint was committed. history_stmt = ( select(TurnEvent) .where(TurnEvent.session_id == session_id, TurnEvent.role != "system") ) if is_isolated: history_stmt = history_stmt.where(TurnEvent.sequence_number > effective_base_seq) elif latest_checkpoint: # HEAD mode: turns since the checkpoint was created history_stmt = history_stmt.where(TurnEvent.created_at >= latest_checkpoint.created_at) history_stmt = history_stmt.order_by(TurnEvent.sequence_number.asc()).limit(MAX_CONTEXT_TURNS) recent_turns = db.scalars(history_stmt).all() # Reconstruct the unified prompt using Smriti memory engine (scope-aware) prompt_text = build_prompt_from_checkpoints(checkpoints, recent_turns, payload.message) # Store user turn in DB (for future memory queries), but we send the reconstructed prompt to the LLM user_turn = TurnEvent( session_id=session_id, repo_id=repo_id, role="user", content=payload.message, provider=payload.provider, model=payload.model, sequence_number=next_seq, ) db.add(user_turn) db.flush() # The reconstructed prompt bypasses standard chat roles to ensure strict cross-model continuation messages = [{"role": "user", "content": prompt_text}] # Select adapter try: adapter = ( get_mock_adapter() if payload.use_mock else get_adapter(payload.provider, allow_mock=False) ) except ProviderNotConfiguredError as e: raise HTTPException(status_code=422, detail=str(e)) # Call provider try: reply_text = adapter.send(messages, model=payload.model) except Exception as e: raise HTTPException(status_code=502, detail=f"Provider error: {e}") # Store assistant turn assistant_turn = TurnEvent( session_id=session_id, repo_id=repo_id, role="assistant", content=reply_text, provider=payload.provider, model=payload.model, sequence_number=next_seq + 1, ) db.add(assistant_turn) # Update session active provider/model session.active_provider = payload.provider session.active_model = payload.model session.updated_at = _utcnow() db.commit() turn_count = next_seq + 2 # user + assistant return SendMessageResponse( reply=reply_text, session_id=session_id, turn_count=turn_count, provider=payload.provider, model=payload.model, ) # ── Manual commit endpoint ──────────────────────────────────────────────────── @router.post("/commit", response_model=CommitResponse, status_code=201) def manual_commit(payload: ManualCommitRequest, db: Session = Depends(get_db)): """ Manually create a Commit from the current session state. The commit captures whatever structured state the user provides. """ repo_id = uuid.UUID(payload.repo_id) session_id = uuid.UUID(payload.session_id) repo = _get_repo(repo_id, db) session = db.get(ChatSession, session_id) if not session or session.repo_id != repo_id: raise HTTPException(status_code=404, detail="Session not found") # Derive branch identity from the session — the session row is the source of truth. # For main-branch sessions: parent = latest main commit (existing behaviour). # For fork-branch sessions: parent = latest commit on the session's own branch, # falling back to the fork source checkpoint when no branch-local commits exist yet. session_branch = session.branch_name # e.g. "main" or "branch-2026-03-21" if session_branch == "main": parent = _get_latest_commit(repo_id, db) else: parent = _get_latest_commit_on_branch(repo_id, session_branch, db) if parent is None and session.forked_from_checkpoint_id is not None: # First checkpoint on this fork — its parent is the fork source parent = db.get(CommitModel, session.forked_from_checkpoint_id) parent_id = parent.id if parent else None commit_hash = _generate_commit_hash(str(repo_id), payload.message) # Git provenance for repo-state drift detection. Namespaced under # "repo_state" so context_blob stays open for other uses. context_blob = {"repo_state": payload.repo_state} if payload.repo_state else {} commit = CommitModel( repo_id=repo_id, commit_hash=commit_hash, parent_commit_id=parent_id, branch_name=session_branch, # Agent identity: explicit payload value wins over the session's # active provider. Allows a CLI caller (or any client) to tag the # checkpoint with a real agent name instead of just the provider # (e.g. "claude-code" vs. "anthropic"). Falls back to the session's # active provider when the payload omits it. author_agent=payload.author_agent or session.active_provider, author_type="llm", project_root=payload.project_root, message=payload.message, summary=payload.summary, objective=payload.objective, decisions=payload.decisions, assumptions=payload.assumptions, tasks=payload.tasks, open_questions=payload.open_questions, entities=payload.entities, artifacts=payload.artifacts, metadata_={"session_id": str(session_id)}, context_blob=context_blob, ) db.add(commit) repo.updated_at = _utcnow() db.commit() db.refresh(commit) return commit # ── Head endpoint ───────────────────────────────────────────────────────────── @router.get("/spaces/{repo_id}/head", response_model=HeadResponse) def get_head(repo_id: uuid.UUID, db: Session = Depends(get_db)): """Return the latest commit + latest session metadata for a Space.""" _get_repo(repo_id, db) latest_commit = _get_latest_commit(repo_id, db) # Latest session session_stmt = ( select(ChatSession) .where(ChatSession.repo_id == repo_id) .order_by(ChatSession.updated_at.desc()) .limit(1) ) latest_session = db.scalars(session_stmt).first() return HeadResponse( repo_id=repo_id, commit_hash=latest_commit.commit_hash if latest_commit else None, commit_id=latest_commit.id if latest_commit else None, summary=latest_commit.summary if latest_commit else None, objective=latest_commit.objective if latest_commit else None, latest_session_id=latest_session.id if latest_session else None, latest_session_title=latest_session.title if latest_session else None, ) # ── Space state endpoint (multi-branch) ────────────────────────────────────── def _get_active_branch_heads( repo_id: uuid.UUID, db: Session, limit: int = ACTIVE_BRANCHES_CAP ) -> list[CommitModel]: """Return the most recent checkpoint on each non-main branch of this space that has at least one session with branch_disposition == 'active'. Branches marked 'integrated' or 'abandoned' are excluded — they no longer appear in ## Active branches or ## Divergence signal. They remain in the lineage tree and in `smriti branch list` for history. Implemented in two steps so the SQL stays portable across the SQLAlchemy dialects we use in tests: first fetch every non-main checkpoint, then collapse per branch in Python and filter by session disposition. """ # Step 1: find which branches have at least one active session. active_branches_stmt = ( select(ChatSession.branch_name) .where( ChatSession.repo_id == repo_id, ChatSession.branch_name != "main", ChatSession.branch_disposition == "active", ) .distinct() ) active_branch_names = set(db.scalars(active_branches_stmt).all()) if not active_branch_names: return [] # Step 2: find the latest checkpoint per active branch. stmt = ( select(CommitModel) .where( CommitModel.repo_id == repo_id, CommitModel.branch_name != "main", ) .order_by(CommitModel.created_at.desc()) ) seen: set[str] = set() heads: list[CommitModel] = [] for commit in db.scalars(stmt): if commit.branch_name in seen: continue if commit.branch_name not in active_branch_names: seen.add(commit.branch_name) continue seen.add(commit.branch_name) heads.append(commit) if len(heads) >= limit: break return heads def _compute_space_divergence( main_head: CommitModel, branch_heads: list[CommitModel], ) -> Optional[DivergenceSummary]: """Compute decision-level divergence between main HEAD and each non-main branch HEAD. Returns `None` if no branch differs, otherwise a capped `DivergenceSummary`. Reuses `lineage._diff_lists` so matching stays consistent with what `smriti compare` surfaces — case and punctuation differences already normalize to the same key, so two agents saying "Use Pydantic" and "use pydantic." do not look divergent. """ # Imported lazily so we don't create a hard circular import at # module load time between chat.py and lineage.py (both can be # imported in either order by the FastAPI app bootstrap). from app.api.routes.lineage import _diff_lists pairs: list[DivergencePair] = [] for branch_head in branch_heads[:DIVERGENCE_BRANCHES_CAP]: only_main, only_branch, _shared = _diff_lists( main_head.decisions or [], branch_head.decisions or [], ) if not only_main and not only_branch: continue pairs.append( DivergencePair( branch_name=branch_head.branch_name, branch_commit_hash=branch_head.commit_hash, main_only_decisions=only_main[:DIVERGENCE_DECISIONS_CAP], branch_only_decisions=only_branch[:DIVERGENCE_DECISIONS_CAP], ) ) return DivergenceSummary(pairs=pairs) if pairs else None def _get_active_claims( repo_id: uuid.UUID, db: Session, limit: int = 10 ) -> list[ActiveClaimSummary]: """Active, non-expired work claims for a space, most-recently-claimed first. Claims bound to an active worktree are enriched with cached git drift (path, branch, dirty file count, ahead/behind vs origin/main, last commit). Shared by `GET /spaces/{id}/state` and `GET /api/v5/current/spaces/{id}` so both surfaces report active work identically. """ now = _utcnow() claims_stmt = ( select(WorkClaim) .where( WorkClaim.repo_id == repo_id, WorkClaim.status == "active", WorkClaim.expires_at > now, ) .order_by(WorkClaim.claimed_at.desc()) .limit(limit) ) active_claims: list[ActiveClaimSummary] = [] for wc in db.scalars(claims_stmt): base_hash = None if wc.base_commit_id: base_commit = db.get(CommitModel, wc.base_commit_id) base_hash = base_commit.commit_hash[:7] if base_commit else None worktree_summary = None if wc.worktree_id: worktree = db.get(WorkTree, wc.worktree_id) if worktree and worktree.status == "active": probed = _probe_worktree( str(worktree.id), worktree.path, worktree.branch_name, ) if probed: worktree_summary = ActiveWorktreeSummary(**probed) active_claims.append( ActiveClaimSummary( id=wc.id, agent=wc.agent, branch_name=wc.branch_name, scope=wc.scope, task_id=wc.task_id, worktree_id=wc.worktree_id, worktree=worktree_summary, intent_type=wc.intent_type, claimed_at=wc.claimed_at, expires_at=wc.expires_at, base_commit_hash=base_hash, ) ) return active_claims FRESHNESS_NEW_CHECKPOINTS_CAP = 5 @router.get("/spaces/{repo_id}/state", response_model=SpaceStateResponse) def get_space_state( repo_id: uuid.UUID, since_commit_id: Optional[uuid.UUID] = Query(None, description="Checkpoint ID to check freshness against"), db: Session = Depends(get_db), ): """Return the richer, multi-branch continuation brief for a space. Contains: - space header (id, name, description) - main-branch HEAD metadata (same shape as /head) - full main-branch HEAD commit (or None if no checkpoints yet) - up to ACTIVE_BRANCHES_CAP most recent non-main branch heads - a divergence summary when any active branch disagrees with main on decisions — capped at DIVERGENCE_BRANCHES_CAP branches and DIVERGENCE_DECISIONS_CAP decisions per side per pair. - freshness signal (when since_commit_id is provided): whether HEAD has moved past the caller's known checkpoint, and if so, a compact list of new checkpoints. The divergence section is the lightweight signal an agent or human needs to notice cross-branch drift without reading the full compare output. If the signal fires, the agent is expected to run `smriti_compare` on the specific branches for the full diff. """ repo = _get_repo(repo_id, db) main_head_commit = _get_latest_commit(repo_id, db) # Latest session (same query get_head uses — mirror it so clients can # treat `head` in the state response as interchangeable with /head). session_stmt = ( select(ChatSession) .where(ChatSession.repo_id == repo_id) .order_by(ChatSession.updated_at.desc()) .limit(1) ) latest_session = db.scalars(session_stmt).first() head_resp = HeadResponse( repo_id=repo_id, commit_hash=main_head_commit.commit_hash if main_head_commit else None, commit_id=main_head_commit.id if main_head_commit else None, summary=main_head_commit.summary if main_head_commit else None, objective=main_head_commit.objective if main_head_commit else None, latest_session_id=latest_session.id if latest_session else None, latest_session_title=latest_session.title if latest_session else None, ) commit_resp = ( CommitResponse.model_validate(main_head_commit) if main_head_commit else None ) active_branch_commits = _get_active_branch_heads( repo_id, db, limit=ACTIVE_BRANCHES_CAP ) active_branches = [ ActiveBranchSummary( branch_name=c.branch_name, commit_id=c.id, commit_hash=c.commit_hash, message=c.message or "", author_agent=c.author_agent, created_at=c.created_at, # Cap at ~200 chars so the Active branches section stays a # pointer, not a brief. Callers render this on one line. summary=(c.summary or "")[:200], ) for c in active_branch_commits ] divergence = None if main_head_commit and active_branch_commits: divergence = _compute_space_divergence(main_head_commit, active_branch_commits) # Active work claims — extracted helper, shared with GET /api/v5/current. active_claims = _get_active_claims(repo_id, db) # Freshness check: if since_commit_id is provided, determine whether # HEAD has moved and list new checkpoints since the caller's base. freshness = None if since_commit_id and main_head_commit: since_commit = db.get(CommitModel, since_commit_id) since_hash = since_commit.commit_hash[:7] if since_commit else str(since_commit_id)[:7] current_hash = main_head_commit.commit_hash[:7] if since_commit_id == main_head_commit.id: freshness = FreshnessInfo( changed=False, since_commit_hash=since_hash, current_head_hash=current_hash, ) else: # Walk main checkpoints backward from HEAD to find those newer # than since_commit_id. Stop at since_commit_id or chain end. new_commits: list[FreshnessCheckpoint] = [] stmt = ( select(CommitModel) .where( CommitModel.repo_id == repo_id, CommitModel.branch_name == "main", ) .order_by(CommitModel.created_at.desc()) ) for c in db.scalars(stmt): if c.id == since_commit_id: break new_commits.append(FreshnessCheckpoint( commit_hash=c.commit_hash[:7], author_agent=c.author_agent, message=c.message or "", created_at=c.created_at, )) if len(new_commits) >= 50: # safety cap break freshness = FreshnessInfo( changed=True, since_commit_hash=since_hash, current_head_hash=current_hash, new_checkpoints_count=len(new_commits), new_checkpoints=new_commits[:FRESHNESS_NEW_CHECKPOINTS_CAP], ) return SpaceStateResponse( space=SpaceBrief( id=repo.id, name=repo.name, description=repo.description, project_root=repo.project_root, ), head=head_resp, commit=commit_resp, active_branches=active_branches, active_claims=active_claims, divergence=divergence, freshness=freshness, ) # ── Provider status endpoint ────────────────────────────────────────────────── @router.get("/providers") def list_providers(): """Return provider configuration status. Safe to expose — never returns keys.""" return providers_status()