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