Newer
Older
navi-1 / navi / api / routes / sessions / listing.py
"""Creating and listing sessions."""

import asyncio
from datetime import datetime, timedelta, timezone
from typing import Annotated

import structlog
from fastapi import Depends, HTTPException, Query

from navi.api.deps import (
    get_memory_store,
    get_profile_registry,
    get_scheduler,
    get_session_store,
    require_user,
)
from navi.auth import User
from navi.config import settings
from navi.core import ProfileRegistry, SessionStore
from navi.core.scheduler import RecallScheduler
from navi.exceptions import ProfileNotFound
from navi.memory import MemoryStore
from navi.profiles.base import admin_only_blocked

from .common import CreateSessionRequest, _session_admin, _session_owner

log = structlog.get_logger()
from ._router import router  # the shared /sessions router


@router.post("", status_code=201)
async def create_session(
    payload: CreateSessionRequest,
    store: Annotated[SessionStore, Depends(get_session_store)],
    profiles: Annotated[ProfileRegistry, Depends(get_profile_registry)],
    memory: Annotated[MemoryStore, Depends(get_memory_store)],
    user: Annotated[User, Depends(require_user)],
) -> dict:
    profile_id = payload.profile_id
    if not profile_id:
        profile_id = settings.navi_default_profile_id
    if not profile_id:
        raise HTTPException(status_code=400, detail="profile_id is required and no default profile is configured")

    try:
        profile = profiles.get(profile_id)
    except ProfileNotFound:
        raise HTTPException(status_code=404, detail=f"Profile '{profile_id}' not found")

    if admin_only_blocked(profile, user.role):
        raise HTTPException(
            status_code=403, detail=f"Profile '{profile_id}' requires admin access"
        )

    session = await store.create(profile_id, user_id=_session_owner(user))

    # Fire-and-forget: extract memory from any stale sessions (last active > 30 min ago)
    asyncio.create_task(_process_stale_sessions(store, memory, user_id=_session_owner(user)))

    return {
        "session_id": session.id,
        "profile_id": session.profile_id,
        "created_at": session.created_at.isoformat(),
    }


async def _process_stale_sessions(store: SessionStore, memory: MemoryStore, user_id: str | None = None) -> None:
    """Extract facts from sessions idle for >30 min that haven't been processed yet."""
    from navi.api.deps import get_backend_registry
    from navi.config import settings
    from navi.memory.extractor import extract_and_update

    cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
    try:
        # Candidates first, histories later. Listing used to hydrate every
        # session's messages on every POST /sessions just to decide which ones
        # were idle; a summary says the same thing and costs nothing.
        candidates = await store.list_all_summaries(user_id=user_id, is_admin=False)
        backend = get_backend_registry().get("ollama")
    except Exception:
        return

    for candidate in candidates:
        if not candidate.display_count:
            continue
        if candidate.user_id is None:
            continue  # skip legacy sessions — no multi-user memory for unowned sessions
        if candidate.last_active >= cutoff:
            continue  # still active
        extracted_at = await memory.get_extracted_at(candidate.id)
        if extracted_at and extracted_at >= candidate.last_active.isoformat():
            continue  # already up to date
        # Only a session that actually needs extraction pays for its history.
        session = await store.get(candidate.id)
        if session is None:
            continue
        try:
            await extract_and_update(session, backend, settings.ollama_default_model, memory)
        except Exception:
            log.warning("memory.extraction_failed", session_id=session.id, exc_info=True)


@router.get("")
async def list_sessions(
    store: Annotated[SessionStore, Depends(get_session_store)],
    user: Annotated[User, Depends(require_user)],
    scheduler: Annotated[RecallScheduler, Depends(get_scheduler)],
    limit: Annotated[int | None, Query(ge=1, le=100)] = None,
    offset: Annotated[int | None, Query(ge=0)] = None,
    profile_id: str | None = None,
    search: str | None = Query(None),
    special: bool | None = Query(None),
) -> dict | list[dict]:
    # special=None and special=False both mean the regular list; special=True
    # exposes the service sessions (e.g. Synapse reactions), which the sidebar
    # hides behind its toggle.
    special_filter = False if special is None else special
    is_admin = _session_admin(user)

    if limit is None and offset is None:
        # No paging means "the whole list" (the TUI picker asks this way). The
        # sidebar only needs a count and a snippet, and both come from
        # aggregates: hydrating every session's history to produce them cost
        # ~300 ms a call and allocated the entire hot history for nothing.
        sessions = await store.list_all_summaries(
            user_id=_session_owner(user), is_admin=is_admin, special=special_filter
        )
        if profile_id and not search:
            sessions = [s for s in sessions if s.profile_id == profile_id]
        ids = [s.id for s in sessions]
        matches = await store.search_matches(ids, search) if search else {}
        pending_ids = await scheduler.get_pending_session_ids(ids)
        return [
            _session_summary(
                s, match=matches.get(s.id, ([], "")), has_pending_recall=s.id in pending_ids
            )
            for s in sessions
        ]

    page_limit = limit or 30
    page_offset = offset or 0
    if search:
        sessions = await store.search_list(
            limit=page_limit + 1,
            offset=page_offset,
            user_id=_session_owner(user),
            is_admin=is_admin,
            search=search,
            special=special_filter,
        )
    else:
        sessions = await store.list_page(
            limit=page_limit + 1,
            offset=page_offset,
            profile_id=profile_id,
            user_id=_session_owner(user),
            is_admin=is_admin,
            special=special_filter,
        )
    items = sessions[:page_limit]
    # Search results are summaries too; "where does the term appear" is one
    # aggregate over the page's sessions, not a reason to load their history.
    matches = await store.search_matches([s.id for s in items], search) if search else {}
    pending_ids = await scheduler.get_pending_session_ids([s.id for s in items])
    return {
        "items": [
            _session_summary(
                s, match=matches.get(s.id, ([], "")), has_pending_recall=s.id in pending_ids
            )
            for s in items
        ],
        "limit": page_limit,
        "offset": page_offset,
        "has_more": len(sessions) > page_limit,
        "next_offset": page_offset + len(items),
    }


def _session_summary(
    session,
    match: tuple[list[int], str] | None = None,
    has_pending_recall: bool = False,
) -> dict:
    summary = {
        "session_id": session.id,
        "profile_id": session.profile_id,
        "name": session.name,
        "message_count": session.display_count,
        "preview": session.preview,
        "pinned": session.pinned,
        "special": session.special,
        "has_pending_recall": has_pending_recall,
        "created_at": session.created_at.isoformat(),
        "last_active": session.last_active.isoformat(),
    }
    if match is not None:
        summary["match_indices"], summary["match_preview"] = match
    return summary